@homeflare/alchemy 0.11.0 β 0.12.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 +3 -3
- package/dist/caddy/admin-guard.d.ts +1 -1
- package/dist/caddy/index.js.map +2 -2
- package/dist/cloudflare/client.d.ts +1 -1
- package/dist/cloudflare/index.js.map +2 -2
- package/dist/cloudflare/lock-rules.d.ts +1 -1
- package/dist/forgejo/branch-protection-form.d.ts +1 -1
- package/dist/forgejo/branch-protection.d.ts +1 -1
- package/dist/forgejo/index.js.map +10 -10
- package/dist/forgejo/org-actions-secrets.d.ts +1 -1
- package/dist/forgejo/org-label.d.ts +1 -1
- package/dist/forgejo/org-team.d.ts +1 -1
- package/dist/forgejo/repo-webhook.d.ts +1 -1
- package/dist/forgejo/repository.d.ts +2 -2
- package/dist/forgejo/repository.d.ts.map +1 -1
- package/dist/forgejo/resource.d.ts +2 -2
- package/dist/forgejo/team-member.d.ts +1 -1
- package/dist/openbao/auth-role.d.ts +1 -1
- package/dist/openbao/bao-http.d.ts +4 -4
- package/dist/openbao/cloudflare-permission-groups.d.ts +1 -1
- package/dist/openbao/cloudflare-role-form.d.ts +2 -2
- package/dist/openbao/cloudflare-role-form.d.ts.map +1 -1
- package/dist/openbao/cloudflare-roles-expand.d.ts +1 -1
- package/dist/openbao/digest.d.ts +1 -1
- package/dist/openbao/forgejo-bootstrap-parse.d.ts +2 -2
- package/dist/openbao/forgejo-bootstrap-parse.d.ts.map +1 -1
- package/dist/openbao/forgejo-bootstrap.d.ts +1 -1
- package/dist/openbao/index.js.map +14 -14
- package/dist/openbao/mount.d.ts +1 -1
- package/dist/openbao/pki-role.d.ts +1 -1
- package/dist/openbao/policy.d.ts +2 -2
- package/dist/openbao/proxmox-role-form.d.ts +2 -2
- package/dist/openbao/proxmox-role-form.d.ts.map +1 -1
- package/dist/openbao/proxmox-role.d.ts +10 -10
- package/dist/openbao/ssh-role.d.ts +3 -3
- package/dist/proxmox/acl.d.ts +2 -2
- package/dist/proxmox/acl.d.ts.map +1 -1
- package/dist/proxmox/api-token-form.d.ts +2 -2
- package/dist/proxmox/api-token.d.ts +11 -11
- package/dist/proxmox/ceph-daemon-form.d.ts +3 -3
- package/dist/proxmox/ceph-daemon.d.ts +19 -19
- package/dist/proxmox/ceph-daemon.d.ts.map +1 -1
- package/dist/proxmox/ceph-flag.d.ts +5 -5
- package/dist/proxmox/ceph-fs-wire.d.ts +5 -5
- package/dist/proxmox/ceph-fs.d.ts +16 -16
- package/dist/proxmox/ceph-osd-tree.d.ts +16 -16
- package/dist/proxmox/ceph-osd.d.ts +5 -5
- package/dist/proxmox/ceph-pool-form.d.ts +13 -13
- package/dist/proxmox/ceph-pool-form.d.ts.map +1 -1
- package/dist/proxmox/ceph-pool.d.ts +3 -3
- package/dist/proxmox/client.d.ts +2 -2
- package/dist/proxmox/credentials.d.ts +8 -8
- package/dist/proxmox/credentials.d.ts.map +1 -1
- package/dist/proxmox/firewall-alias-form.d.ts +1 -1
- package/dist/proxmox/firewall-alias.d.ts +1 -1
- package/dist/proxmox/generated/pbs.d.ts +1 -1
- package/dist/proxmox/generated/pve.d.ts +1 -1
- package/dist/proxmox/group.d.ts +8 -8
- package/dist/proxmox/ha-resource.d.ts +3 -2
- package/dist/proxmox/ha-resource.d.ts.map +1 -1
- package/dist/proxmox/ha-rule-form.d.ts +5 -5
- package/dist/proxmox/ha-rule.d.ts +3 -3
- package/dist/proxmox/index.d.ts +6 -0
- package/dist/proxmox/index.d.ts.map +1 -1
- package/dist/proxmox/index.js +356 -142
- package/dist/proxmox/index.js.map +59 -56
- package/dist/proxmox/lease-cache.d.ts +4 -4
- package/dist/proxmox/lxc-harness.d.ts +1 -1
- package/dist/proxmox/lxc-props.d.ts +2 -2
- package/dist/proxmox/lxc.d.ts +3 -3
- package/dist/proxmox/members.d.ts +3 -3
- package/dist/proxmox/network-apply-read.d.ts +5 -5
- package/dist/proxmox/network-apply.d.ts +9 -9
- package/dist/proxmox/node-network-form.d.ts +6 -6
- package/dist/proxmox/node-network.d.ts +17 -17
- package/dist/proxmox/node-network.d.ts.map +1 -1
- package/dist/proxmox/notification-target.d.ts +3 -2
- package/dist/proxmox/notification-target.d.ts.map +1 -1
- package/dist/proxmox/pbs-datastore-guard.d.ts +1 -1
- package/dist/proxmox/pbs-datastore.d.ts +10 -10
- package/dist/proxmox/pbs-prune-job.d.ts +2 -2
- package/dist/proxmox/pbs-sync-job.d.ts +4 -4
- package/dist/proxmox/pbs-verify-job.d.ts +1 -1
- package/dist/proxmox/pool.d.ts +4 -4
- package/dist/proxmox/provision-baseline.d.ts +74 -0
- package/dist/proxmox/provision-baseline.d.ts.map +1 -0
- package/dist/proxmox/provision-bootstrap.d.ts +33 -0
- package/dist/proxmox/provision-bootstrap.d.ts.map +1 -0
- package/dist/proxmox/provision-cli-fake.d.ts +38 -0
- package/dist/proxmox/provision-cli-fake.d.ts.map +1 -0
- package/dist/proxmox/provision-declare.d.ts +25 -0
- package/dist/proxmox/provision-declare.d.ts.map +1 -0
- package/dist/proxmox/replication-job.d.ts +9 -8
- package/dist/proxmox/replication-job.d.ts.map +1 -1
- package/dist/proxmox/resource.d.ts +2 -2
- package/dist/proxmox/resource.d.ts.map +1 -1
- package/dist/proxmox/sdn-apply-read.d.ts +7 -7
- package/dist/proxmox/sdn-apply.d.ts +1 -1
- package/dist/proxmox/sdn-subnet-form.d.ts +1 -1
- package/dist/proxmox/sdn-subnet.d.ts +5 -5
- package/dist/proxmox/sdn-vnet.d.ts +5 -6
- package/dist/proxmox/sdn-vnet.d.ts.map +1 -1
- package/dist/proxmox/storage.d.ts +4 -3
- package/dist/proxmox/storage.d.ts.map +1 -1
- package/dist/proxmox/user.d.ts +3 -2
- package/dist/proxmox/user.d.ts.map +1 -1
- package/dist/proxmox/values.d.ts +1 -1
- package/dist/proxmox/zfs-pool-write.d.ts +1 -1
- package/dist/proxmox/zfs-pool.d.ts +5 -5
- package/dist/talos/credentials.d.ts +1 -1
- package/dist/talos/credentials.d.ts.map +1 -1
- package/dist/talos/index.js.map +1 -1
- package/docs/adopt-verify.md +21 -5
- package/docs/adopted-deploys.md +1 -1
- package/docs/ownership.md +26 -1
- package/docs/provision-baseline.md +106 -0
- package/docs/proxmox-lxc-adopt.md +2 -2
- package/docs/proxmox-lxc.md +5 -4
- package/docs/proxmox.md +6 -0
- package/package.json +1 -1
- package/src/caddy/admin-guard.ts +1 -1
- package/src/caddy/local-admin.ts +1 -1
- package/src/cloudflare/client.ts +1 -1
- package/src/cloudflare/lock-rules.ts +1 -1
- package/src/forgejo/branch-protection-form.ts +1 -1
- package/src/forgejo/branch-protection.ts +1 -1
- package/src/forgejo/client.ts +1 -1
- package/src/forgejo/org-actions-secrets.ts +1 -1
- package/src/forgejo/org-label.ts +1 -1
- package/src/forgejo/org-team.ts +1 -1
- package/src/forgejo/repo-webhook.ts +1 -1
- package/src/forgejo/repository.ts +2 -2
- package/src/forgejo/resource.ts +2 -2
- package/src/forgejo/team-member.ts +1 -1
- package/src/openbao/README.md +4 -4
- package/src/openbao/auth-role.ts +1 -1
- package/src/openbao/bao-http.test.ts +2 -2
- package/src/openbao/bao-http.ts +6 -6
- package/src/openbao/bao-status.test.ts +1 -1
- package/src/openbao/bao-status.ts +1 -1
- package/src/openbao/cloudflare-group-scope.test.ts +1 -1
- package/src/openbao/cloudflare-permission-groups.test.ts +1 -1
- package/src/openbao/cloudflare-permission-groups.ts +3 -3
- package/src/openbao/cloudflare-policy.ts +1 -1
- package/src/openbao/cloudflare-role-form.ts +2 -2
- package/src/openbao/cloudflare-roles-expand.ts +1 -1
- package/src/openbao/digest.ts +1 -1
- package/src/openbao/forgejo-bootstrap-parse.test.ts +3 -3
- package/src/openbao/forgejo-bootstrap-parse.ts +2 -2
- package/src/openbao/forgejo-bootstrap.ts +4 -4
- package/src/openbao/kubernetes-role-form.test.ts +5 -5
- package/src/openbao/mount.ts +1 -1
- package/src/openbao/pki-role.ts +1 -1
- package/src/openbao/policy-wire.test.ts +3 -3
- package/src/openbao/policy.ts +2 -2
- package/src/openbao/proxmox-role-form.ts +5 -5
- package/src/openbao/proxmox-role.ts +11 -11
- package/src/openbao/ssh-role.ts +3 -3
- package/src/proxmox/acl.ts +4 -4
- package/src/proxmox/adopt-noop.test.ts +6 -6
- package/src/proxmox/api-token-form.ts +4 -4
- package/src/proxmox/api-token.ts +13 -13
- package/src/proxmox/ceph-daemon-form.ts +3 -3
- package/src/proxmox/ceph-daemon.ts +20 -20
- package/src/proxmox/ceph-flag.ts +7 -7
- package/src/proxmox/ceph-fs-wire.ts +7 -7
- package/src/proxmox/ceph-fs.ts +18 -18
- package/src/proxmox/ceph-osd-tree.ts +16 -16
- package/src/proxmox/ceph-osd.ts +6 -6
- package/src/proxmox/ceph-pool-adopt.test.ts +14 -14
- package/src/proxmox/ceph-pool-form.ts +13 -13
- package/src/proxmox/ceph-pool.ts +6 -6
- package/src/proxmox/client.test.ts +3 -3
- package/src/proxmox/client.ts +3 -3
- package/src/proxmox/credentials.test.ts +6 -8
- package/src/proxmox/credentials.ts +8 -8
- package/src/proxmox/firewall-alias-form.ts +1 -1
- package/src/proxmox/firewall-alias.ts +1 -1
- package/src/proxmox/generated/pbs.ts +1 -1
- package/src/proxmox/generated/pve.ts +1 -1
- package/src/proxmox/group.ts +10 -10
- package/src/proxmox/ha-resource.ts +3 -2
- package/src/proxmox/ha-rule-form.ts +5 -5
- package/src/proxmox/ha-rule.ts +3 -3
- package/src/proxmox/index.test.ts +7 -0
- package/src/proxmox/index.ts +14 -0
- package/src/proxmox/lease-cache.test.ts +19 -19
- package/src/proxmox/lease-cache.ts +4 -4
- package/src/proxmox/lxc-adopt.test.ts +3 -3
- package/src/proxmox/lxc-create-form.ts +1 -1
- package/src/proxmox/lxc-guards.test.ts +4 -4
- package/src/proxmox/lxc-harness.ts +3 -3
- package/src/proxmox/lxc-judge.test.ts +5 -5
- package/src/proxmox/lxc-lifecycle.ts +1 -1
- package/src/proxmox/lxc-props.ts +2 -2
- package/src/proxmox/lxc-read.test.ts +3 -3
- package/src/proxmox/lxc-strict-adopt.test.ts +1 -1
- package/src/proxmox/lxc-volume.test.ts +9 -9
- package/src/proxmox/lxc-volume.ts +1 -1
- package/src/proxmox/lxc-wire.ts +1 -1
- package/src/proxmox/lxc.ts +3 -3
- package/src/proxmox/members.test.ts +1 -1
- package/src/proxmox/members.ts +3 -3
- package/src/proxmox/metric-server-otel.ts +2 -2
- package/src/proxmox/network-apply-read.ts +10 -10
- package/src/proxmox/network-apply.ts +10 -10
- package/src/proxmox/node-network-form.ts +7 -7
- package/src/proxmox/node-network.ts +18 -18
- package/src/proxmox/notification-target.ts +3 -2
- package/src/proxmox/pbs-datastore-guard.ts +1 -1
- package/src/proxmox/pbs-datastore.ts +10 -10
- package/src/proxmox/pbs-prune-job.ts +2 -2
- package/src/proxmox/pbs-sync-job.ts +4 -4
- package/src/proxmox/pbs-verify-job.ts +1 -1
- package/src/proxmox/pool.test.ts +2 -2
- package/src/proxmox/pool.ts +4 -4
- package/src/proxmox/provision-baseline.test.ts +87 -0
- package/src/proxmox/provision-baseline.ts +162 -0
- package/src/proxmox/provision-bootstrap.test.ts +132 -0
- package/src/proxmox/provision-bootstrap.ts +135 -0
- package/src/proxmox/provision-cli-fake.ts +217 -0
- package/src/proxmox/provision-declare.test.ts +111 -0
- package/src/proxmox/provision-declare.ts +103 -0
- package/src/proxmox/replication-job.ts +9 -8
- package/src/proxmox/resource.ts +3 -3
- package/src/proxmox/sdn-apply-read.test.ts +16 -16
- package/src/proxmox/sdn-apply-read.ts +7 -7
- package/src/proxmox/sdn-apply.ts +3 -3
- package/src/proxmox/sdn-subnet-form.ts +1 -1
- package/src/proxmox/sdn-subnet.ts +5 -5
- package/src/proxmox/sdn-vnet.ts +5 -6
- package/src/proxmox/storage.ts +4 -3
- package/src/proxmox/user.ts +3 -2
- package/src/proxmox/values.test.ts +2 -2
- package/src/proxmox/values.ts +1 -1
- package/src/proxmox/zfs-pool-write.ts +1 -1
- package/src/proxmox/zfs-pool.ts +6 -6
- package/src/talos/credentials.ts +3 -3
- package/src/talos/values.test.ts +9 -9
- package/src/verify/cli.test.ts +1 -1
package/README.md
CHANGED
|
@@ -104,7 +104,7 @@ Cloudflare and Proxmox roles, plugins β plus `assertBaoIdentity` (call it firs
|
|
|
104
104
|
β **Metadata only.** Alchemy stores props and attributes unencrypted, so no secret, CA key,
|
|
105
105
|
plugin `env` or OIDC client secret is declarable. Every family defaults to `retain` on destroy.
|
|
106
106
|
|
|
107
|
-
β **Nothing is adopted without `--adopt`** (0.9.0), for every family and even when the live object
|
|
107
|
+
β **Nothing is adopted without `--adopt`** (0.9.0, first published as 0.10.0), for every family and even when the live object
|
|
108
108
|
is identical to the declaration; an interrupted create of this stack's own resumes when its row
|
|
109
109
|
can prove it (one killed while a prop was an `Output` needs `--adopt`). The same rule holds for
|
|
110
110
|
`HostFile`, `LaunchdJob`, `CaddyConfig` and `ProxmoxLxc`: [docs/ownership.md](./docs/ownership.md).
|
|
@@ -159,8 +159,8 @@ with `HostFile`; `caddyProviders()` provides the transport, `http://127.0.0.1:20
|
|
|
159
159
|
|
|
160
160
|
## Proxmox β `@homeflare/alchemy/proxmox`
|
|
161
161
|
|
|
162
|
-
PVE and PBS objects over the PVE API,
|
|
163
|
-
to a guest, and why: [docs/proxmox.md](./docs/proxmox.md).
|
|
162
|
+
PVE and PBS objects over the PVE API, `ProxmoxLxc` for containers, and the provisioning baseline
|
|
163
|
+
every cluster needs first. What it never does to a guest, and why: [docs/proxmox.md](./docs/proxmox.md).
|
|
164
164
|
|
|
165
165
|
## Adopt verifier β `hf-adopt-verify` / `@homeflare/alchemy/verify`
|
|
166
166
|
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* Refused unless the transport is at that default; declaring `admin <address>` is the fix.
|
|
21
21
|
* Β· a Host the new config does not allow: `origins` without it, or β with no `origins` β a Host
|
|
22
22
|
* outside Caddy's loopback defaults (`localhost`, `[::1]`, `127.0.0.1` at the listen port).
|
|
23
|
-
* Every later call answers 403 (admin.go allowedOrigins / checkHost; MEASURED on
|
|
23
|
+
* Every later call answers 403 (admin.go allowedOrigins / checkHost; MEASURED on a Mac host's
|
|
24
24
|
* Caddy 2.11.4 β `host not allowed`). The transport also sends `Origin: http://<Host>`, which
|
|
25
25
|
* Caddy checks against the same list (originAllowed), so one check covers both.
|
|
26
26
|
* Β· `enforce_origin` over a unix socket: this transport, like Caddy's CLI (cmd/commandfuncs.go
|
package/dist/caddy/index.js.map
CHANGED
|
@@ -13,12 +13,12 @@
|
|
|
13
13
|
"/**\n * Whether this apply RESUMES a create or replace the engine already started for this resource AND\n * the live object it will find is proven that generation's own β the one case in which a stateless\n * `reconcile` may write over a live object without `--adopt`.\n *\n * β
HOW THE ENGINE TELLS US. Alchemy calls `diff` whenever a state row exists, and hands it `output`\n * = that row's attributes (alchemy beta.79 Plan.ts). A row with NO attributes is exactly an\n * unfinished generation: `creating` (a create interrupted after its write, before its commit),\n * `replacing` (the same for a replace), or `deleting` re-driven as a create. A fresh create is\n * never diffed at all. So the diff of such a row decides, and the apply that follows (the same\n * instance id: Apply.ts reuses the row's) finds the note.\n * β A ROW PROVES NOTHING BY ITSELF (red team, 2026-09-21). Apply commits it BEFORE reconcile runs, so\n * a deploy killed before the claim β an upstream that failed, a Ctrl-C β leaves a row whose create\n * never asked whose object sat at its identity. π΄ MEASURED (openbao/adopt-holes.test.ts): with the\n * name an Output, the next deploy \"resumed\" that create and wrote over another owner's role, in\n * every family that had a note on the row alone. So the note is left only when the family's own\n * `read`, asked as the recovery read (probe.ts ownedRead: a recorded instance, a row carrying the\n * whole declaration, and a live object that matches it), answers \"ours\". For a `creating` row\n * that is the question Plan.ts already asked; for a `replacing` row it is the only time it is asked.\n * β οΈ WHY NOT THE STATE STORE ALONE. At reconcile both look the same: Apply commits the fresh create's\n * `creating` row before calling reconcile, so a row with this instance id is always there.\n * β
THE CHANNEL IS ALCHEMY'S OWN: `Artifacts`, the per-FQN bag the engine shares between one plan\n * and its apply (Artifacts.ts: \"diff computes β¦ create / update reads the same artifact\").\n * Deploy.ts and the Alchemist session both hold one store across the pair.\n * β οΈ IT FAILS CLOSED. No bag, a bag the plan never wrote, or an object the read cannot prove ours\n * reads as a fresh create, and adopt.ts then asks for `--adopt` β the safe direction.\n * β
AN UNPROVEN RESUME IS STILL MARKED UNFINISHED (`noteUnfinished`), so `--adopt` can take it over\n * the way Alchemy's own \"Cannot resume creating β¦ Re-run with --adopt\" offers for a create β while\n * a FRESH replace's new generation, which no diff ever saw without attributes, stays unadoptable.\n */\nimport { Unowned } from 'alchemy/AdoptPolicy';\nimport { Artifacts } from 'alchemy/Artifacts';\nimport * as Effect from 'effect/Effect';\nimport * as Option from 'effect/Option';\n\nconst PROVEN = 'homeflare/ownership/resumes';\nconst UNFINISHED = 'homeflare/ownership/unfinished';\n\nconst note = (key: string, instanceId: string): Effect.Effect<void> =>\n Effect.gen(function* () {\n const bag = yield* Effect.serviceOption(Artifacts);\n if (Option.isSome(bag)) yield* bag.value.set(key, instanceId);\n });\n\nconst noted = (key: string, instanceId: string): Effect.Effect<boolean> =>\n Effect.gen(function* () {\n const bag = yield* Effect.serviceOption(Artifacts);\n if (Option.isNone(bag)) return false;\n return (yield* bag.value.get<string>(key)) === instanceId;\n });\n\n/**\n * `diff`'s note for a row with no attributes: `instanceId` is an unfinished generation of this\n * resource's own, which `--adopt` may resume when nothing proves it (adopt.ts adoptable). Returns\n * `undefined`, a diff's \"defer to the engine\".\n */\nexport const noteUnfinished = (instanceId: string): Effect.Effect<undefined> =>\n Effect.as(note(UNFINISHED, instanceId), undefined);\n\n/**\n * Note `instanceId` unfinished, and a proven resume when `recovered` β the family's `read` of the\n * row's props, asked with no attributes β answers a plain object: ours. `Unowned`, nothing, or a\n * read that fails or dies proves nothing. β οΈ Never fails the plan: an unproven resume is refused\n * at apply instead, unless `--adopt`.\n */\nexport const noteResume = <R>(\n instanceId: string,\n recovered: Effect.Effect<unknown, unknown, R>,\n): Effect.Effect<void, never, R> =>\n Effect.gen(function* () {\n yield* noteUnfinished(instanceId);\n const unproven = () => Effect.succeed(undefined);\n const found = yield* recovered.pipe(Effect.catch(unproven), Effect.catchDefect(unproven));\n if (found === undefined || found === null || Unowned.is(found)) return;\n yield* note(PROVEN, instanceId);\n });\n\n/** Whether the plan for this apply noted `instanceId` as a proven resumed create or replace. */\nexport const resumes = (instanceId: string): Effect.Effect<boolean> => noted(PROVEN, instanceId);\n\n/** Whether the plan for this apply saw `instanceId` as an unfinished generation (proven or not). */\nexport const unfinished = (instanceId: string): Effect.Effect<boolean> =>\n noted(UNFINISHED, instanceId);\n\n/** The fields of a `read` or `diff` call provingResumes uses; a family's own input carries more. */\ntype Ask = {\n readonly id: string;\n readonly fqn: string;\n readonly instanceId: string;\n readonly olds: unknown;\n readonly output: unknown;\n};\ntype Hook = Effect.Effect<unknown, unknown, unknown>;\n\n/** The two hooks provingResumes wraps, as Alchemy's ProviderService declares them. */\ninterface Resumable {\n read?(input: Ask): Hook;\n diff?(input: Ask): Hook;\n}\n\n/**\n * A provider whose `diff`, for a row with no attributes, first asks the provider's own `read` whether\n * the live object is that unfinished generation's (noteResume), then runs as written. Every Bao.*\n * family is built through it, so their diffs answer such a row with a plain `undefined`.\n */\nexport const provingResumes = <S extends Resumable>(service: S): S => {\n const { diff, read } = service;\n if (diff === undefined || read === undefined) return service;\n const proving: Resumable['diff'] = (input) =>\n Effect.gen(function* () {\n if (input.output === undefined) {\n yield* noteResume(input.instanceId, read({ ...input, output: undefined }));\n }\n return yield* diff(input);\n });\n // β οΈ The one cast: `diff` keeps the family's own signature β it is the family's diff, run after.\n return { ...service, diff: proving } as S;\n};\n",
|
|
14
14
|
"/**\n * The questions ownership/ asks of Alchemy's state store: does a row record this instance, and with\n * its whole declaration (probe.ts); is this apply a create rather than a replace's new generation\n * (adopt.ts); and β when reconcile refuses a create β forget the row Apply committed for it\n * (adopt.ts). Alchemy's own AWS.EC2.SecurityGroup provider reads the store the same way: the Stack\n * service names the stack and stage, and `State` yields the store.\n *\n * β οΈ WITHOUT A STACK OR STATE IN CONTEXT, or with a store that errors, a row reads as \"not\n * recorded\" β the object is then `Unowned`, the safe answer β and nothing is forgotten, which\n * leaves the one gap forgetRefusedCreate exists to close. `alchemy deploy` and the Alchemist\n * session provide both.\n */\nimport { Stack } from 'alchemy/Stack';\nimport { State, type StateService, isActionState } from 'alchemy/State/State';\nimport * as Effect from 'effect/Effect';\nimport * as Option from 'effect/Option';\nimport { wholeDeclaration } from './whole.ts';\n\ntype Where = { readonly stack: string; readonly stage: string };\n\n/** The store, where this stack lives, and what it declares β or undefined outside a stack. */\nconst storeOf = Effect.gen(function* () {\n const stack = yield* Effect.serviceOption(Stack);\n const state = yield* Effect.serviceOption(State);\n if (Option.isNone(stack) || Option.isNone(state)) return undefined;\n const store: StateService = yield* state.value;\n const where: Where = { stack: stack.value.name, stage: stack.value.stage };\n return { resources: stack.value.resources, store, where };\n});\n\nconst rowAt = (store: StateService, where: Where, fqn: string) =>\n store.get({ ...where, fqn }).pipe(Effect.orElseSucceed(() => undefined));\n\n/** One generation of a persisted row, as far as ownership/ reads it. */\nexport type Generation = {\n readonly instanceId?: unknown;\n readonly status?: unknown;\n readonly props?: unknown;\n readonly attr?: unknown;\n readonly old?: unknown;\n /** Set by Apply on the `updating` row of an adoption, until that update commits (Apply.ts). */\n readonly adopting?: unknown;\n};\n\n/** The generation of a persisted row that carries `instanceId`: the row itself, or one in `old`. */\nconst generationOf = (row: unknown, instanceId: string): Generation | undefined => {\n let at = row;\n while (typeof at === 'object' && at !== null && !isActionState(at as never)) {\n const generation = at as Generation;\n if (generation.instanceId === instanceId) return generation;\n at = generation.old;\n }\n return undefined;\n};\n\n/** The generation `instanceId` names β at the FQN, or at a former FQN `renamedFrom` names. */\nexport const recordedGeneration = (\n fqn: string,\n instanceId: string,\n): Effect.Effect<Generation | undefined> =>\n Effect.gen(function* () {\n const found = yield* storeOf;\n if (found === undefined) return undefined;\n for (const at of [fqn, ...(found.resources[fqn]?.FormerFqns ?? [])]) {\n const generation = generationOf(yield* rowAt(found.store, found.where, at), instanceId);\n if (generation !== undefined) return generation;\n }\n return undefined;\n });\n\n/**\n * What the state store says of `instanceId` for this resource (at its FQN, or at a former FQN its\n * `renamedFrom` names β the planner migrates that row before it recovers it):\n * Β· `absent` β no row holds it. β
This is what separates the probe from the recovery read: the\n * probe's instance id is minted for the probe (Plan.ts `generateInstanceId()`), 128 random bits\n * no row can hold.\n * Β· `partial` β a row holds it, but its props lack part of the declaration: a prop stripped at\n * commit because it was still an Output (whole.ts). Those props cannot prove an object ours.\n * Β· `whole` β a row holds it with every prop the declaration names.\n */\nexport type Recorded = 'absent' | 'partial' | 'whole';\n\nexport const recordedInstance = (fqn: string, instanceId: string): Effect.Effect<Recorded> =>\n Effect.gen(function* () {\n const generation = yield* recordedGeneration(fqn, instanceId);\n if (generation === undefined) return 'absent';\n return (yield* wholeDeclaration(fqn, generation.props)) ? 'whole' : 'partial';\n });\n\n/**\n * Whether this apply's `instanceId` is a CREATE: the row Apply committed for it is `creating`.\n * β A REPLACE'S NEW GENERATION IS NOT ONE. Apply commits it as `replacing`, and the planner never\n * offers it adoption β the probe runs only for a resource with no state β so `--adopt` must not\n * let it write over whatever sits at its new identity (adopt.ts adoptsAtApply).\n */\nexport const isCreate = (fqn: string, instanceId: string): Effect.Effect<boolean> =>\n Effect.map(\n recordedGeneration(fqn, instanceId),\n (generation) => generation?.status === 'creating',\n );\n\n/**\n * β A REFUSED CREATE MUST NOT LEAVE A ROW THAT CLAIMS THE OBJECT IT REFUSED. Apply commits a\n * `creating` row for this instance before it calls reconcile, and a failed reconcile leaves it\n * there. The next plan's recovery read would then find our instance id and an object that can\n * match its props β a prop that was an Output is stripped from the row, and an omitted prop is\n * \"not managed\" β and adopt exactly what this apply refused. π΄ MEASURED 2026-09-21: Bao.Mount\n * and Bao.AuthMethod did (openbao/adopt-core.test.ts). So the row goes, and state reads as if\n * the create never started: the next plan probes, and the probe answers `Unowned`.\n * β οΈ ONLY a `creating` row of THIS instance with no attributes β the one Apply just wrote. A\n * `replacing` row carries the previous generation in `old` and is never touched.\n */\nexport const forgetRefusedCreate = (fqn: string, instanceId: string): Effect.Effect<void> =>\n Effect.gen(function* () {\n const found = yield* storeOf;\n if (found === undefined) return;\n const row = yield* rowAt(found.store, found.where, fqn);\n const mine = row as { status?: unknown; instanceId?: unknown; attr?: unknown } | undefined;\n if (mine?.status !== 'creating' || mine.instanceId !== instanceId || mine.attr !== undefined) {\n return;\n }\n yield* found.store.delete({ ...found.where, fqn }).pipe(Effect.ignore);\n });\n",
|
|
15
15
|
"/**\n * Whether a state row's props say everything the declaration says β the test that separates a row\n * whose props can prove an object ours from one that cannot (probe.ts, rows.ts).\n *\n * β
WHY A ROW CAN BE MISSING PART OF ITS DECLARATION. Apply commits the `creating` (or `replacing`)\n * row BEFORE reconcile runs, and strips every prop still an Output at that moment (alchemy\n * beta.79 Apply.ts `commit` β Diff.ts `stripUnresolved`); a JSON store then drops the key. So a\n * row written while an upstream was still being created carries a HOLE where that prop was.\n * β A HOLE IS \"UNKNOWN\", NOT \"UNMANAGED\". Most families read an absent prop as \"not managed\", so a\n * recovery read comparing the live object with holed props proves nothing: π΄ MEASURED 2026-09-21\n * (openbao/adopt-holes.test.ts), a Bao.Mount whose `defaultLeaseTtl` was an Output, killed before\n * its reconcile ran, adopted β and tuned β another owner's mount on the next deploy; with the\n * NAME an Output, every role family read an empty name, found nothing, and the resumed create\n * wrote over another owner's role. Neither deploy had ever asked whose object it was.\n * β
THE DECLARATION IS WHAT TELLS A HOLE FROM AN OMISSION. It still holds the Output (or the literal)\n * the row lacks, while an optional prop left out is absent from both.\n * β οΈ CONSERVATIVE BY DESIGN: a prop added to the declaration after the crash also reads as a hole.\n * That costs one `--adopt`, never a takeover.\n */\nimport { Stack } from 'alchemy/Stack';\nimport * as Effect from 'effect/Effect';\nimport * as Option from 'effect/Option';\n\nconst absent = (value: unknown): boolean => value === undefined || value === null;\n\nconst isPlainObject = (value: unknown): value is Record<string, unknown> => {\n if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;\n const proto: unknown = Object.getPrototypeOf(value);\n return proto === Object.prototype || proto === null;\n};\n\n/**\n * Whether `recorded` carries a value everywhere `declared` does. Plain objects and arrays are\n * walked; any other declared value β a literal, an Output, an Effect β needs a value in the row.\n */\nexport const carries = (declared: unknown, recorded: unknown): boolean => {\n if (absent(declared)) return true;\n if (absent(recorded)) return false;\n if (Array.isArray(declared)) {\n if (!Array.isArray(recorded)) return false;\n return declared.every((each, index) => carries(each, recorded[index]));\n }\n if (isPlainObject(declared)) {\n if (!isPlainObject(recorded)) return false;\n return Object.entries(declared).every(([key, each]) => carries(each, recorded[key]));\n }\n return true;\n};\n\n/**\n * Whether `props` β one generation's, from the state store β carry the whole declaration of the\n * resource at `fqn`. β FALSE WITH NOTHING TO COMPARE: no Stack in context, or a resource no longer\n * declared (an orphan's delete). That object then reads as `Unowned` and Apply leaves it in place\n * with a note, rather than deleting what it cannot prove it made.\n */\nexport const wholeDeclaration = (fqn: string, props: unknown): Effect.Effect<boolean> =>\n Effect.map(Effect.serviceOption(Stack), (stack) => {\n const declared = Option.isSome(stack) ? stack.value.resources[fqn] : undefined;\n return declared !== undefined && carries(declared.Props, props);\n });\n",
|
|
16
|
-
"/**\n * Refuse a config whose `admin` block would cut the provider off from Caddy, or expose the admin\n * API β checked on the ADAPTED JSON (`POST /adapt`), before anything is loaded.\n *\n * β A Caddyfile can move its own admin endpoint. After `POST /load`, Caddy swaps the admin server\n * to the new config's (admin.go replaceLocalAdminServer), so each of these would succeed ONCE and\n * then strand the resource β no read-back, no drift check, no next deploy β or worse:\n * Β· `admin off` (`disabled`): nothing can manage this Caddy through its API again.\n * Β· a `listen` off loopback (`:2019`, `0.0.0.0:2019`, a LAN address): the unauthenticated API is\n * on the network. Decision 23: never expose :2019.\n * Β· a `listen` on another port, socket or loopback ADDRESS than the transport's: the provider\n * loses Caddy. Go binds exactly the literal address given (`[::1]:2019` is not reachable at\n * `127.0.0.1:2019`), and a hostname binds its first IPv4 address (net ListenConfig.Listen,\n * `addrs.first(isIPv4)`), so `localhost:2019` is `127.0.0.1:2019`. MEASURED 2026-09-21 on a\n * throwaway Caddy 2.11.4: after loading `admin [::1]:<p>`, `127.0.0.1:<p>` refused the\n * connection; after `admin localhost:<p>`, `[::1]:<p>` did.\n * Β· NO `admin` address at all: Caddy falls back to DefaultAdminListen β `localhost:2019`, or\n * `$CADDY_ADMIN` in ITS environment (admin.go replaceLocalAdminServer), which this side cannot\n * see. A Caddy reached any other way (a socket, another port) moves there on the first load.\n * Refused unless the transport is at that default; declaring `admin <address>` is the fix.\n * Β· a Host the new config does not allow: `origins` without it, or β with no `origins` β a Host\n * outside Caddy's loopback defaults (`localhost`, `[::1]`, `127.0.0.1` at the listen port).\n * Every later call answers 403 (admin.go allowedOrigins / checkHost; MEASURED on
|
|
16
|
+
"/**\n * Refuse a config whose `admin` block would cut the provider off from Caddy, or expose the admin\n * API β checked on the ADAPTED JSON (`POST /adapt`), before anything is loaded.\n *\n * β A Caddyfile can move its own admin endpoint. After `POST /load`, Caddy swaps the admin server\n * to the new config's (admin.go replaceLocalAdminServer), so each of these would succeed ONCE and\n * then strand the resource β no read-back, no drift check, no next deploy β or worse:\n * Β· `admin off` (`disabled`): nothing can manage this Caddy through its API again.\n * Β· a `listen` off loopback (`:2019`, `0.0.0.0:2019`, a LAN address): the unauthenticated API is\n * on the network. Decision 23: never expose :2019.\n * Β· a `listen` on another port, socket or loopback ADDRESS than the transport's: the provider\n * loses Caddy. Go binds exactly the literal address given (`[::1]:2019` is not reachable at\n * `127.0.0.1:2019`), and a hostname binds its first IPv4 address (net ListenConfig.Listen,\n * `addrs.first(isIPv4)`), so `localhost:2019` is `127.0.0.1:2019`. MEASURED 2026-09-21 on a\n * throwaway Caddy 2.11.4: after loading `admin [::1]:<p>`, `127.0.0.1:<p>` refused the\n * connection; after `admin localhost:<p>`, `[::1]:<p>` did.\n * Β· NO `admin` address at all: Caddy falls back to DefaultAdminListen β `localhost:2019`, or\n * `$CADDY_ADMIN` in ITS environment (admin.go replaceLocalAdminServer), which this side cannot\n * see. A Caddy reached any other way (a socket, another port) moves there on the first load.\n * Refused unless the transport is at that default; declaring `admin <address>` is the fix.\n * Β· a Host the new config does not allow: `origins` without it, or β with no `origins` β a Host\n * outside Caddy's loopback defaults (`localhost`, `[::1]`, `127.0.0.1` at the listen port).\n * Every later call answers 403 (admin.go allowedOrigins / checkHost; MEASURED on a Mac host's\n * Caddy 2.11.4 β `host not allowed`). The transport also sends `Origin: http://<Host>`, which\n * Caddy checks against the same list (originAllowed), so one check covers both.\n * Β· `enforce_origin` over a unix socket: this transport, like Caddy's CLI (cmd/commandfuncs.go\n * AdminAPIRequest), sends no Origin there, and a unix listener has no default origins β so\n * checkOrigin answers 403 to every call. MEASURED 2026-09-21 on a throwaway Caddy 2.11.4:\n * `client is not allowed to access from origin ''`, and that Caddy could not be managed again.\n * Β· `remote`: a second, network-facing admin listener (mTLS on :2021) β declare that deliberately,\n * not through this resource.\n * Β· `config.load`: Caddy pulls its config from elsewhere, replacing this one on a timer.\n * β
Read in caddyserver/caddy v2.11.4: admin.go AdminConfig / allowedOrigins / originAllowed, and\n * httpcaddyfile/options.go parseOptAdmin (an `admin { β¦ }` block with no address still emits\n * `listen` as the default, so a present `listen` is the norm, not a change).\n */\nimport type { CaddyAdminListener } from './admin.ts';\n\ntype AdminBlock = {\n disabled?: boolean;\n listen?: string;\n origins?: string[];\n enforce_origin?: boolean;\n remote?: unknown;\n config?: { load?: unknown };\n};\n\n/** Caddy's compiled-in admin port (admin.go DefaultAdminListen `localhost:2019`). */\nconst DEFAULT_ADMIN_PORT = 2019;\n\nconst LOOPBACK_HOST = /^(?:localhost|127(?:\\.\\d{1,3}){3}|\\[?::1\\]?)$/;\n\ntype Listen = { kind: 'tcp'; host: string; port: number } | { kind: 'unix'; path: string };\n\n/** Caddy network address β a listener, or a description of why it is not one we can check. */\nconst parseListen = (listen: string): Listen | string => {\n if (listen.includes('{')) return 'a placeholder, which cannot be checked before it runs';\n const unix = /^unix(?:gram|packet)?\\/(.+?)(?:\\|[0-7]{3,4})?$/.exec(listen);\n if (unix !== null) return { kind: 'unix', path: unix[1] ?? '' };\n if (/^[a-z0-9]+\\//.test(listen) && !/^tcp[46]?\\//.test(listen)) {\n return `network ${JSON.stringify(listen.split('/')[0])}, not tcp or unix`;\n }\n const address = listen.replace(/^tcp[46]?\\//, '');\n const colon = address.lastIndexOf(':');\n const port = Number(address.slice(colon + 1));\n if (colon === -1 || !Number.isInteger(port) || port <= 0) return 'no single port';\n return { host: address.slice(0, colon), kind: 'tcp', port };\n};\n\nconst unbracket = (host: string): string => host.replace(/^\\[(.*)\\]$/, '$1');\n\n/**\n * Whether a transport sending `hostHeader` reaches a listener bound to `listenHost`.\n * β
The Host stands in for the dial address: they are the same unless `hostHeader` was set for a\n * forward, where it should name the far side's address anyway. `localhost` on the transport's\n * side may resolve to either family (node tries both), so it reaches any loopback listener; a\n * literal address reaches only itself.\n */\nconst reaches = (hostHeader: string, listenHost: string): boolean => {\n const dialled = unbracket(hostHeader.slice(0, hostHeader.lastIndexOf(':')));\n const listening = unbracket(listenHost);\n return (\n dialled === 'localhost' || dialled === (listening === 'localhost' ? '127.0.0.1' : listening)\n );\n};\n\nconst sameListener = (listen: Listen, transport: CaddyAdminListener): boolean =>\n listen.kind === 'tcp'\n ? transport.kind === 'tcp' &&\n listen.port === transport.port &&\n reaches(transport.hostHeader, listen.host)\n : transport.kind === 'unix' && listen.path === transport.path;\n\n/** Caddy's allowed Hosts for a loopback listener with no `origins` (admin.go allowedOrigins). */\nconst defaultOrigins = (port: number): string[] =>\n ['localhost', '[::1]', '127.0.0.1'].map((host) => `${host}:${String(port)}`);\n\nconst originHost = (origin: string): string | undefined => {\n if (!origin.includes('://')) return origin;\n try {\n const url = new URL(origin);\n // β οΈ originAllowed compares schemes when an entry has one; the transport sends `http://`.\n return url.protocol === 'http:' ? url.host : undefined;\n } catch {\n return undefined;\n }\n};\n\nconst listenProblem = (admin: AdminBlock, transport: CaddyAdminListener): string | undefined => {\n if (admin.listen === undefined) {\n const atDefault =\n transport.kind === 'tcp' &&\n transport.port === DEFAULT_ADMIN_PORT &&\n reaches(transport.hostHeader, 'localhost');\n return atDefault\n ? undefined\n : 'no `admin` address: Caddy would fall back to its default listener (localhost:2019, or ' +\n '$CADDY_ADMIN), away from where this provider reaches it β declare `admin <address>`';\n }\n const listen = parseListen(admin.listen);\n const shown = JSON.stringify(admin.listen);\n if (typeof listen === 'string') {\n return `admin listen ${shown} is ${listen}; declare a literal loopback address`;\n }\n if (listen.kind === 'tcp' && !LOOPBACK_HOST.test(listen.host)) {\n return `admin listen ${shown} is not loopback β it would put the admin API on the network`;\n }\n return sameListener(listen, transport)\n ? undefined\n : `admin listen ${shown} moves the admin API away from where this provider reaches it`;\n};\n\n/** Refusals for the adapted config's `admin` block, given where the transport reaches Caddy. */\nexport const adminProblems = (adapted: unknown, transport: CaddyAdminListener): string[] => {\n const admin =\n typeof adapted === 'object' && adapted !== null\n ? ((adapted as { admin?: AdminBlock }).admin ?? {})\n : {};\n const found: string[] = [];\n if (admin.disabled === true) {\n return ['`admin off` would disable the admin API this resource manages Caddy through'];\n }\n if (admin.remote !== undefined) found.push('`admin.remote` opens a network-facing admin API');\n if (admin.config?.load !== undefined) {\n found.push(\n '`admin.config.load` makes Caddy pull its config from elsewhere, replacing this one',\n );\n }\n const moved = listenProblem(admin, transport);\n if (moved !== undefined) found.push(moved);\n if (transport.kind === 'tcp') {\n const allowed = admin.origins?.map(originHost) ?? defaultOrigins(transport.port);\n if (!allowed.includes(transport.hostHeader)) {\n const which =\n admin.origins === undefined\n ? `Caddy's default origins (no \\`origins\\` declared) ${JSON.stringify(allowed)}`\n : `admin origins ${JSON.stringify(admin.origins)}`;\n found.push(\n `${which} do not allow Host ${transport.hostHeader}, which this provider sends β ` +\n 'Caddy would refuse it every call after this one',\n );\n }\n } else if (admin.enforce_origin === true) {\n found.push(\n '`enforce_origin` over a unix socket: this transport (like the Caddy CLI) sends no Origin ' +\n 'there, so Caddy would answer 403 to every call',\n );\n }\n return found;\n};\n",
|
|
17
17
|
"/**\n * A tripwire for the obvious ways a secret ends up in Alchemy state through these providers.\n *\n * β WHY IT EXISTS. Alchemy persists every prop UNENCRYPTED in its state store β the same reason\n * the OpenBao families are metadata-only (openbao/policy.ts). A LaunchdJob's `environment` and\n * `programArguments`, and a HostFile's `content`, are props. A token declared there is a token\n * copied into the state store, into every plan diff, and into the plist or file on disk.\n * β
THE ALTERNATIVE, NAMED IN EVERY REFUSAL: let a secret renderer (openbao-agent, or any other)\n * write the secret to a root-owned file, and declare only its PATH β `FOO_TOKEN_FILE=/path`,\n * `--token-file /path`. The job reads the file at start; the state holds a path.\n *\n * β οΈ A TRIPWIRE, NOT A SCANNER. It catches the names and shapes people actually type. A token in\n * an innocently named variable passes; nothing here can make a secret safe to declare.\n */\nimport type { PlistDict, PlistValue } from './plist.ts';\n\nconst ALTERNATIVE =\n 'props are stored unencrypted in Alchemy state. Have a secret renderer (e.g. openbao-agent) ' +\n 'write it to a root-owned file and declare the file path instead (FOO_FILE=/path, --foo-file /path).';\n\n/** Names that end in a word meaning \"this is the secret itself\". `*_FILE`, `*_PATH` never match. */\nconst SECRET_NAME =\n /(?:^|_)(?:TOKEN|SECRET|PASSWORD|PASSWD|PASSPHRASE|API_?KEY|ACCESS_KEY|PRIVATE_KEY|SECRET_KEY|CREDENTIALS?)$/i;\n\n/** `--token=β¦` / `-password=β¦` style flags carrying their value inline. */\nconst INLINE_FLAG = /^--?[a-z0-9-]*(?:token|password|passwd|passphrase|secret|api-?key)=./i;\n\n/** A flag whose NEXT argv element is the secret: `--token abc`. */\nconst BARE_FLAG = /^--?(?:[a-z0-9]+-)*(?:token|password|passwd|passphrase|secret|api-?key)$/i;\n\nexport const PRIVATE_KEY = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/;\n\n/** Paths of every string leaf in a plist value holding a private key. */\nconst keyLeaves = (value: PlistValue, path: string): string[] => {\n if (typeof value === 'string') return PRIVATE_KEY.test(value) ? [path] : [];\n if (Array.isArray(value)) {\n return (value as readonly PlistValue[]).flatMap((item, index) =>\n keyLeaves(item, `${path}[${String(index)}]`),\n );\n }\n if (typeof value === 'object' && value !== null) {\n return Object.entries(value).flatMap(([key, item]) => keyLeaves(item, `${path}.${key}`));\n }\n return [];\n};\n\n/**\n * Refusals for a job's environment, argv and extraKeys. Empty means nothing tripped.\n * β
extraKeys too: it is a prop like the others, so it lands in state just the same.\n */\nexport const jobSecretProblems = (\n environment: Readonly<Record<string, string>> | undefined,\n argv: readonly string[],\n extraKeys?: PlistDict,\n): string[] => {\n const found: string[] = [];\n for (const [name, value] of Object.entries(environment ?? {})) {\n if (SECRET_NAME.test(name))\n found.push(`environment ${name} looks like a secret: ${ALTERNATIVE}`);\n else if (PRIVATE_KEY.test(value))\n found.push(`environment ${name} holds a private key: ${ALTERNATIVE}`);\n }\n for (const [index, arg] of argv.entries()) {\n // β Never echo the argument itself: if it IS a secret, the refusal would print it.\n if (INLINE_FLAG.test(arg)) {\n found.push(`programArguments[${String(index)}] passes a secret inline: ${ALTERNATIVE}`);\n } else if (BARE_FLAG.test(arg) && index + 1 < argv.length) {\n found.push(`programArguments[${String(index + 1)}] follows a secret flag: ${ALTERNATIVE}`);\n } else if (PRIVATE_KEY.test(arg)) {\n found.push(`programArguments[${String(index)}] holds a private key: ${ALTERNATIVE}`);\n }\n }\n for (const path of keyLeaves(extraKeys ?? {}, 'extraKeys')) {\n found.push(`${path} holds a private key: ${ALTERNATIVE}`);\n }\n return found;\n};\n\n/** Refusals for a HostFile's content. */\nexport const fileSecretProblems = (content: string): string[] =>\n PRIVATE_KEY.test(content) ? [`content holds a private key: ${ALTERNATIVE}`] : [];\n",
|
|
18
18
|
"/**\n * A tripwire for the obvious ways a secret ends up in a CaddyConfig's `caddyfile` prop.\n *\n * β WHY IT EXISTS. Alchemy stores every prop UNENCRYPTED in its state store and prints it in plan\n * diffs (the reason launchd's secret-tripwire.ts and the metadata-only OpenBao families exist).\n * The whole Caddyfile is one prop, so a token typed into it is a token in state.\n * β
THE ALTERNATIVE, NAMED IN EVERY REFUSAL: a RUNTIME placeholder, which Caddy resolves in its own\n * process and which therefore never enters the Caddyfile, the state, the adapted JSON or\n * `autosave.json` (caddyserver/caddy v2.11.4 replacer.go):\n * Β· `{env.NAME}` β the Caddy process's environment; start Caddy with `--envfile <path>` where a\n * secret renderer (openbao-agent) writes that file. A rotated value needs a Caddy restart.\n * Β· `{file./path}` β the file's contents, trailing newline trimmed, read when the placeholder is\n * evaluated; the renderer writes the file.\n * β οΈ `{$NAME}` IS NOT ONE OF THEM. It is substituted at ADAPT time (caddyfile/parse.go\n * replaceEnvVars), so the value lands in the adapted JSON β readable at `GET /config/` and\n * written to `autosave.json`. It is not refused (the prop holds only the name), but it is worse.\n * `{$NAME:default}` is different: the default IS in the prop, and is checked as a literal.\n * β οΈ A TRIPWIRE, NOT A SCANNER. It catches the names and shapes people actually type; a secret in\n * an innocent-looking argument passes. Nothing here makes a secret safe to declare.\n */\nimport { PRIVATE_KEY } from '../launchd/secret-tripwire.ts';\n\nconst ALTERNATIVE =\n 'props are stored unencrypted in Alchemy state. Use a runtime placeholder instead: {env.NAME} ' +\n '(Caddy started with --envfile <file a secret renderer writes>) or {file./path/to/secret}.';\n\n/** A subdirective whose whole name says \"my argument is the secret\". `*_file` never matches. */\nconst SECRET_WORD =\n /^(?:token|api_token|auth_token|access_token|secret|client_secret|shared_secret|secret_key|password|passwd|passphrase|api_key|apikey|private_key|mac_key|hmac_key)$/i;\n\n/** Directives whose second argument is a DNS provider's credential: `dns cloudflare <token>`. */\nconst DNS_DIRECTIVE = /^(?:dns|acme_dns)$/;\n\nconst HEADER_DIRECTIVE = /^(?:header|header_up|header_down|request_header)$/;\nconst CREDENTIAL_HEADER =\n /^[+-]?(?:authorization|proxy-authorization|cookie|x-api-key|api-key|x-auth-token|x-vault-token)$/i;\n\n/** Shapes that are a credential wherever they appear. The label is what the refusal says. */\nconst SHAPES: readonly (readonly [RegExp, string])[] = [\n [/^(?:gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{20,})$/, 'a GitHub token'],\n [/^(?:AKIA|ASIA)[0-9A-Z]{16}$/, 'an AWS access key id'],\n [/^xox[abposr]-[A-Za-z0-9-]{10,}$/, 'a Slack token'],\n [/^sk-(?:ant-|proj-)?[A-Za-z0-9_-]{20,}$/, 'an API secret key'],\n [/^eyJ[A-Za-z0-9_-]{8,}\\.eyJ[A-Za-z0-9_-]{8,}\\.[A-Za-z0-9_-]{8,}$/, 'a JWT'],\n // β
basic_auth replaces placeholders in the hash at provision time (caddyauth/basicauth.go\n // Provision), so `{env.ADMIN_HASH}` works and a literal hash never needs to be declared.\n [/^\\$2[abxy]?\\$\\d{2}\\$[./A-Za-z0-9]{53}$/, 'a bcrypt password hash'],\n [/^\\$argon2(?:id|i|d)\\$/, 'an argon2 password hash'],\n];\n\nconst PLACEHOLDER = /\\{[^{}\\s]+\\}/g;\nconst AUTH_SCHEME = /^(?:bearer|basic|token|digest)$/i;\n\n/**\n * β `{$NAME:default}` IS A LITERAL. Caddy splices the default in at ADAPT time whenever NAME is\n * unset (caddyfile/parse.go replaceEnvVars: SplitN on `:`), so a secret typed as the default is\n * in the prop, the state and the adapted JSON. MEASURED 2026-09-21 on a throwaway Caddy 2.11.4:\n * `respond \"{$UNSET:literal-default}\"` adapted to the body `literal-default`. So each line is\n * checked as Caddy sees it with every such variable unset. The RUNTIME replacer has no default\n * syntax (replacer.go looks the whole key up), so `{env.X}` stays the safe form.\n */\nconst ENV_DEFAULT = /\\{\\$[^{}\\s:]*:([^}]*)\\}/g;\nconst withDefaults = (line: string): string => line.replace(ENV_DEFAULT, '$1');\n\n/** One line's tokens: whitespace-separated, \"double-quoted\" and `backquoted` kept whole, # comments dropped. */\nexport const tokensOf = (line: string): string[] => {\n const tokens: string[] = [];\n let current = '';\n let quote: string | undefined;\n for (let index = 0; index < line.length; index += 1) {\n const char = line[index] ?? '';\n if (quote !== undefined) {\n if (char === '\\\\' && quote === '\"' && index + 1 < line.length) {\n index += 1;\n current += line[index] ?? '';\n } else if (char === quote) quote = undefined;\n else current += char;\n } else if (char === '\"' || char === '`') {\n quote = char;\n } else if (/\\s/.test(char)) {\n if (current !== '') tokens.push(current);\n current = '';\n } else if (char === '#' && current === '') {\n break; // a comment starts only at the beginning of a token (caddyfile lexer)\n } else current += char;\n }\n if (current !== '') tokens.push(current);\n return tokens;\n};\n\n/**\n * True when nothing but placeholders (and an auth scheme word) is left of these tokens.\n * β οΈ A bare `{` / `}` is a block brace, not a value: `dns cloudflare {` opens a block whose\n * `api_token` line is checked on its own.\n */\nconst placeholderOnly = (tokens: readonly string[]): boolean =>\n tokens\n .flatMap((token) => token.replace(PLACEHOLDER, ' ').split(/\\s+/))\n .filter((part) => part !== '' && part !== '{' && part !== '}' && !AUTH_SCHEME.test(part))\n .length === 0;\n\nconst lineProblems = (tokens: readonly string[], where: string): string[] => {\n const [first = '', second] = tokens;\n const found: string[] = [];\n if (SECRET_WORD.test(first) && tokens.length > 1 && !placeholderOnly(tokens.slice(1))) {\n found.push(`${where}: \\`${first}\\` has a literal value`);\n }\n if (DNS_DIRECTIVE.test(first) && tokens.length > 2 && !placeholderOnly(tokens.slice(2))) {\n found.push(`${where}: \\`${first} ${second ?? ''}\\` has a literal credential`);\n }\n if (HEADER_DIRECTIVE.test(first)) {\n const at = tokens.findIndex((token, index) => index > 0 && CREDENTIAL_HEADER.test(token));\n if (at !== -1 && tokens.length > at + 1 && !placeholderOnly(tokens.slice(at + 1))) {\n found.push(`${where}: \\`${first} ${tokens[at] ?? ''}\\` sets a literal credential`);\n }\n }\n for (const token of tokens) {\n const shape = SHAPES.find(([pattern]) => pattern.test(token));\n // β Never echo the token: if it IS a secret, the refusal would print it.\n if (shape !== undefined) found.push(`${where}: holds what looks like ${shape[1]}`);\n }\n return found;\n};\n\n/** Refusals for a Caddyfile's text. Empty means nothing tripped. */\nexport const caddyfileSecretProblems = (caddyfile: string): string[] => {\n const found: string[] = [];\n if (PRIVATE_KEY.test(caddyfile)) found.push('the Caddyfile holds a PEM private key');\n for (const [index, line] of caddyfile.split('\\n').entries()) {\n found.push(...lineProblems(tokensOf(withDefaults(line)), `line ${String(index + 1)}`));\n }\n return found.map((problem) => `${problem}: ${ALTERNATIVE}`);\n};\n",
|
|
19
19
|
"/**\n * CaddyConfig's props, attributes and validation β the pure half of the provider.\n *\n * β `caddyfile` IS NEVER A SECRET. Alchemy stores props unencrypted, so the whole Caddyfile sits in\n * the state store and in every plan diff. Secrets reach Caddy as runtime placeholders\n * (`{env.NAME}`, `{file./path}`) resolved from files a secret renderer writes β see\n * caddyfile-tripwire.ts, which refuses the obvious literals.\n */\nimport { caddyfileSecretProblems } from './caddyfile-tripwire.ts';\n\nexport interface CaddyConfigProps {\n /**\n * The whole Caddyfile. Applied with `POST /load` (Content-Type `text/caddyfile`); it REPLACES the\n * running config. β Never a secret. β οΈ `import` paths resolve against Caddy's working directory\n * (the API adapts with no file name), so keep them absolute.\n */\n caddyfile: string;\n /**\n * The file Caddy was started with (`caddy run --config <this>`), when the same Caddyfile is also\n * written there β `caddyWithFile()` sets it from its HostFile. Sent as\n * `Caddy-Config-Source-File` so a load keeps Caddy's SIGUSR1 reload-from-file working.\n */\n sourceFile?: string;\n}\n\nexport interface CaddyConfigAttributes {\n /**\n * SHA-256 of the ADAPTED JSON Caddy reported after the last apply (digest.ts canonical form) β\n * never the config itself. Drift is the live config hashing to anything else.\n */\n configSha256: string;\n /** The admin endpoint the config was applied through (no credential; the API has none). */\n endpoint: string;\n /** The `sourceFile` prop as applied. */\n sourceFile?: string;\n}\n\nconst absoluteNormalised = (path: string): boolean =>\n path.startsWith('/') &&\n !path.endsWith('/') &&\n !path.includes('\\x00') &&\n !path\n .split('/')\n .slice(1)\n .some((part) => part === '' || part === '.' || part === '..');\n\nexport const configProblems = (props: CaddyConfigProps): string[] => {\n const found: string[] = [];\n /**\n * β AN EMPTY CADDYFILE IS NOT \"NOTHING TO DO\". It adapts to a config with no apps, and loading\n * it stops every server Caddy runs β every site down, on a templating bug that rendered ''.\n * Emptying Caddy is a deliberate act for a person at the admin API, not a declaration.\n * Text that is not empty but serves nothing (only comments, only global options) is caught\n * after `/adapt`, where it shows as no apps (config-lifecycle.ts servesNothing).\n */\n if (props.caddyfile.trim() === '') {\n found.push('caddyfile is empty β loading it would stop every site Caddy serves');\n }\n if (props.sourceFile !== undefined && !absoluteNormalised(props.sourceFile)) {\n found.push('sourceFile must be an absolute, normalised path');\n }\n found.push(...caddyfileSecretProblems(props.caddyfile));\n return found;\n};\n",
|
|
20
20
|
"/**\n * CaddyConfig's read / diff / reconcile as plain async functions over a CaddyAdmin, so the\n * lifecycle runs against a fake admin server in tests.\n *\n * The comparison is always between DIGESTS OF ADAPTED JSON (digest.ts): what `POST /adapt` makes\n * of the declared Caddyfile, what `GET /config/` reports, and what was stored after the last\n * apply. Three digests, because they answer different questions:\n * Β· declared β stored β the declaration changed.\n * Β· live β stored β DRIFT: a hand `curl` to the API, or a restart that loaded a different file\n * (or an `autosave.json` holding someone else's change, under `--resume`).\n * Β· declared = live β nothing to load, whatever the state says.\n * With NO state there is no stored digest, and the question is whose config is running: see\n * `claimable` below, which both the adoption probe and the apply ask.\n */\nimport type { Diff } from 'alchemy/Diff';\nimport { type CaddyAdmin, CaddyUnreachableError } from './admin.ts';\nimport {\n CaddyAdminError,\n adaptCaddyfile,\n loadCaddyfile,\n readRunningConfig,\n} from './admin-calls.ts';\nimport { adminProblems } from './admin-guard.ts';\nimport {\n type CaddyConfigAttributes,\n type CaddyConfigProps,\n configProblems,\n} from './config-form.ts';\nimport { configDigest } from './digest.ts';\n\nconst refuse = (admin: CaddyAdmin, message: string): Error =>\n new Error(`Caddy.Config at ${admin.endpoint}: ${message}`);\n\nconst short = (digest: string): string => digest.slice(0, 12);\n\ntype Desired = { readonly digest: string; readonly warnings: readonly string[] };\n\n/**\n * β A CONFIG WITH NO APPS SERVES NOTHING. A Caddyfile of only comments or only global options\n * passes the empty-text check (config-form.ts) yet adapts to `{}` or `{\"admin\":β¦}` β MEASURED\n * 2026-09-21 on a throwaway Caddy 2.11.4 β and loading it stops every server: the same outage,\n * from the same templating bug, that the empty check exists to prevent.\n */\nconst servesNothing = (config: unknown): boolean => {\n const apps =\n typeof config === 'object' && config !== null ? (config as { apps?: unknown }).apps : undefined;\n return typeof apps !== 'object' || apps === null || Object.keys(apps).length === 0;\n};\n\n/**\n * Validate the props, adapt the Caddyfile on the running Caddy, and check its `admin` block.\n * β
Every refusal happens HERE, before anything is loaded β and diff calls this at plan time, so a\n * Caddyfile that does not adapt, carries a literal secret, or would strand the admin API fails the\n * PLAN, before any resource (a HostFile holding the same text included) is applied.\n */\nexport const desiredConfig = async (\n admin: CaddyAdmin,\n props: CaddyConfigProps,\n): Promise<Desired> => {\n const found = configProblems(props);\n if (found.length > 0) throw refuse(admin, found.join('; '));\n const adapted = await adaptCaddyfile(admin, props.caddyfile);\n if (servesNothing(adapted.config)) {\n throw refuse(admin, 'the Caddyfile adapts to no apps β loading it would stop every site');\n }\n const guard = adminProblems(adapted.config, admin.listener);\n if (guard.length > 0) throw refuse(admin, guard.join('; '));\n return { digest: configDigest(adapted.config), warnings: adapted.warnings };\n};\n\nconst attributesOf = (\n admin: CaddyAdmin,\n running: unknown,\n sourceFile?: string,\n): CaddyConfigAttributes => ({\n configSha256: configDigest(running),\n endpoint: admin.endpoint,\n ...(sourceFile === undefined ? {} : { sourceFile }),\n});\n\n/** What is running now, as attributes. `sourceFile` is carried over, never discovered. */\nexport const readLive = async (\n admin: CaddyAdmin,\n sourceFile?: string,\n): Promise<CaddyConfigAttributes> =>\n attributesOf(admin, await readRunningConfig(admin), sourceFile);\n\n/**\n * β WITH NO STATE, A RUNNING CONFIG IS THIS STACK'S ONLY IF IT IS THE DECLARED ONE (decision,\n * 2026-09-21: CaddyConfig must not adopt silently β the house rule HostFile and LaunchdJob keep).\n * Anything else was put there by a person, another tool or another stack, and loading over it\n * replaces every site it serves. β
A config that serves nothing (`null`, or no apps) is claimable\n * too: there is nothing to take over, as an empty R2 lock rule set reads as no lock.\n */\nexport const claimable = (running: unknown, declaredDigest: string): boolean =>\n servesNothing(running) || configDigest(running) === declaredDigest;\n\nexport type Probe = {\n readonly attributes: CaddyConfigAttributes;\n /** The live config IS the declared one: adopting it changes nothing. */\n readonly ours: boolean;\n /** Why the declaration could not be compared, when it could not β the Caddy is then not ours. */\n readonly unchecked?: string;\n};\n\n/**\n * The adoption probe β `read` with no state: `undefined` when Caddy serves nothing (plan a create),\n * else the live attributes and whether they are provably ours (the provider brands the rest\n * `Unowned`, and the plan refuses them without `--adopt`).\n * β IT NEVER THROWS OVER THE DECLARATION. The engine also runs this read to recover an interrupted\n * create, with THAT deploy's props (Plan.ts, `status: \"creating\"`) β so a Caddyfile that failed to\n * adapt then would fail every later plan, the one carrying the fix included. What cannot be\n * compared is \"not proven ours\", with the reason; the next diff or apply validates the Caddyfile.\n */\nexport const probeLive = async (\n admin: CaddyAdmin,\n caddyfile: string | undefined,\n sourceFile?: string,\n): Promise<Probe | undefined> => {\n const running = await readRunningConfig(admin);\n if (servesNothing(running)) return undefined;\n const attributes = attributesOf(admin, running, sourceFile);\n if (caddyfile === undefined) {\n return { attributes, ours: false, unchecked: 'the stored Caddyfile is not plain text' };\n }\n try {\n const want = await desiredConfig(admin, { caddyfile });\n return { attributes, ours: claimable(running, want.digest) };\n } catch (cause) {\n // β οΈ Unreachable mid-probe is still \"no Caddy\" (config.ts plans without it), not \"not ours\".\n if (cause instanceof CaddyUnreachableError) throw cause;\n return {\n attributes,\n ours: false,\n unchecked: cause instanceof Error ? cause.message : String(cause),\n };\n }\n};\n\n/**\n * β
NEVER `replace`. Nothing in the props names a different object: a new Caddyfile is a reload of\n * the same Caddy, and `sourceFile` is a header. (Which Caddy is the transport's business β see\n * the β οΈ on `endpoint` in the diff.)\n */\nexport const diffConfig = async (\n admin: CaddyAdmin,\n news: CaddyConfigProps,\n output: CaddyConfigAttributes,\n): Promise<Diff> => {\n const want = await desiredConfig(admin, news);\n const live = await readLive(admin);\n const converged =\n want.digest === live.configSha256 &&\n live.configSha256 === output.configSha256 &&\n news.sourceFile === output.sourceFile &&\n // β οΈ A stack whose transport now reaches ANOTHER Caddy (a new address) must load there; the\n // old one keeps what it has β the same as a delete, which never unloads (see config.ts).\n // Planned as an update, but the state does not vouch for that Caddy: see Authority.\n admin.endpoint === output.endpoint;\n return converged ? { action: 'noop' } : { action: 'update' };\n};\n\nexport type Applied = {\n readonly attributes: CaddyConfigAttributes;\n /** The adapter's warnings (e.g. \"Caddyfile input is not formatted\") β log them, never refuse. */\n readonly warnings: readonly string[];\n /** Whether a `POST /load` was sent (false when Caddy already ran this config). */\n readonly loaded: boolean;\n};\n\n/**\n * What this apply may load over β config.ts decides from the state and the adopt setting:\n * Β· `takeOver` β any running config: the state was applied to THIS Caddy (an update, drift\n * correction, or a create the engine adopted), or adoption is on (`--adopt`, or the\n * resource's own `adopt(true)` β ownership/adopt.ts adoptEnabled).\n * Β· otherwise only a claimable one β plus, as `stored`, the digest the state last recorded: a\n * Caddy reached at a NEW endpoint that runs exactly that is the same config under another name.\n */\nexport type Authority = { readonly takeOver: boolean; readonly stored?: string };\n\n/**\n * Load the Caddyfile unless Caddy already runs it, then read it back and insist it matches.\n * β WITHOUT `takeOver`, A CONFIG THE STACK CANNOT CLAIM IS NEVER LOADED OVER. The engine's adoption\n * probe cannot guard every create: it is skipped while `news` holds an Output β ALWAYS on\n * caddyWithFile's first deploy (`sourceFile` is its HostFile's path) β and a Caddy that was down at\n * plan time read as nothing. Nor does state vouch for a Caddy the transport NOW reaches at another\n * endpoint (diff plans that as an update). Refused here, before any `/load`, like HostFile's.\n */\nexport const reconcileConfig = async (\n admin: CaddyAdmin,\n props: CaddyConfigProps,\n authority: Authority,\n): Promise<Applied> => {\n const want = await desiredConfig(admin, props);\n const running = await readRunningConfig(admin);\n const before = configDigest(running);\n const known = before === authority.stored || claimable(running, want.digest);\n if (!authority.takeOver && !known) {\n throw refuse(\n admin,\n `Caddy runs config ${short(before)}, which this stack did not load, and the Caddyfile adapts ` +\n `to ${short(want.digest)} β loading it would replace every site that config serves. ` +\n 'Deploy with --adopt (or wrap the resource in adopt(true)) to take this Caddy over, or ' +\n 'point caddyProviders() at the Caddy you meant.',\n );\n }\n let warnings = want.warnings;\n const loaded = before !== want.digest;\n if (loaded) {\n try {\n warnings = await loadCaddyfile(admin, props.caddyfile, props.sourceFile);\n } catch (cause) {\n if (!(cause instanceof CaddyAdminError)) throw cause;\n // β
SAY WHAT IS RUNNING NOW. Caddy restores the previous config on a failed load (caddy.go\n // changeConfig); confirm it rather than promise it, because the deploy log is where the\n // person decides whether sites are down.\n const after = await readLive(admin).catch(() => undefined);\n const state =\n after === undefined\n ? 'and the running config could not be read back'\n : after.configSha256 === before\n ? 'Caddy kept the previous config (still serving)'\n : `and the running config is now ${short(after.configSha256)}, not the previous ${short(before)}`;\n throw refuse(admin, `Caddy refused the Caddyfile β ${cause.message}; ${state}`);\n }\n }\n // β οΈ READ BACK, never echo the declaration: a concurrent writer, or a load Caddy answered 200 but\n // did not apply (admin-calls.ts), shows up here as a refusal instead of as stored state that is\n // already wrong.\n const attributes = await readLive(admin, props.sourceFile);\n if (attributes.configSha256 !== want.digest) {\n throw refuse(\n admin,\n `after the load Caddy runs config ${short(attributes.configSha256)}, not the ` +\n `${short(want.digest)} this Caddyfile adapts to β another writer changed it, or it was not applied`,\n );\n }\n return { attributes, loaded, warnings };\n};\n",
|
|
21
|
-
"/**\n * The real CaddyAdmin: this machine's Caddy, over loopback TCP or a unix socket.\n *\n * β
node:http, NOT fetch. MEASURED 2026-09-21 on bun 1.4.0 and node 26.7.0: node:http sends a Host\n * override and dials a unix socket on BOTH runtimes, while node's fetch silently drops a `Host`\n * header (it sent the URL's host) and only Bun's fetch has a `unix` option. The published dist\n * loads under node too (launchd's local-runner.ts makes the same call for the same reason).\n * β LOOPBACK OR A UNIX SOCKET, NOTHING ELSE. The admin API is unauthenticated plaintext; an\n * address off this machine means it is reachable from the network, which decision 23 forbids.\n * Reaching another host's Caddy means forwarding its socket or port to here (SSH), never\n * exposing :2019.\n * β
HOST AND ORIGIN, AS CADDY's OWN CLI SENDS THEM (cmd/commandfuncs.go AdminAPIRequest): over\n * TCP, `Host` and `Origin: http://<host>`; over a socket, `Host: 127.0.0.1` and no Origin, because\n * Caddy skips the Host check on unix listeners (admin.go allowedOrigins). MEASURED against the\n *
|
|
21
|
+
"/**\n * The real CaddyAdmin: this machine's Caddy, over loopback TCP or a unix socket.\n *\n * β
node:http, NOT fetch. MEASURED 2026-09-21 on bun 1.4.0 and node 26.7.0: node:http sends a Host\n * override and dials a unix socket on BOTH runtimes, while node's fetch silently drops a `Host`\n * header (it sent the URL's host) and only Bun's fetch has a `unix` option. The published dist\n * loads under node too (launchd's local-runner.ts makes the same call for the same reason).\n * β LOOPBACK OR A UNIX SOCKET, NOTHING ELSE. The admin API is unauthenticated plaintext; an\n * address off this machine means it is reachable from the network, which decision 23 forbids.\n * Reaching another host's Caddy means forwarding its socket or port to here (SSH), never\n * exposing :2019.\n * β
HOST AND ORIGIN, AS CADDY's OWN CLI SENDS THEM (cmd/commandfuncs.go AdminAPIRequest): over\n * TCP, `Host` and `Origin: http://<host>`; over a socket, `Host: 127.0.0.1` and no Origin, because\n * Caddy skips the Host check on unix listeners (admin.go allowedOrigins). MEASURED against the\n * a Mac host's Caddy 2.11.4: any other Host gets 403 `host not allowed`, a forwarded port included β\n * hence `hostHeader`.\n */\nimport { request as httpRequest } from 'node:http';\nimport {\n type CaddyAdmin,\n type CaddyAdminListener,\n type CaddyAdminResponse,\n CaddyUnreachableError,\n} from './admin.ts';\n\nexport type LocalCaddyAdminOptions = {\n /**\n * `http://<loopback>:<port>` or `unix:///absolute/path.sock`.\n * @default 'http://127.0.0.1:2019' β Caddy's default admin listener (`localhost:2019`).\n */\n readonly address?: string;\n /**\n * The Host (and Origin) to send over TCP, when it is not the address's own: the Caddyfile\n * narrowed `admin { origins β¦ }`, or the port is an SSH forward of the real one (Caddy checks\n * the Host against ITS port, not yours). Form `host:port`.\n */\n readonly hostHeader?: string;\n /**\n * Abort an exchange after this long. @default 60_000\n * β οΈ A `shutdown_delay` in the Caddyfile makes `/load` block that long (caddyhttp app.go Stop);\n * a timeout then fails the deploy while Caddy goes on to apply β the next plan converges.\n */\n readonly timeoutMs?: number;\n /**\n * Retries when the connection is REFUSED (nothing listening yet β a Caddy launchd job that was\n * bootstrapped a moment ago). Only refusals: those never reached Caddy, so re-sending a\n * `POST /load` cannot apply it twice. @default 2\n */\n readonly retries?: number;\n /** @default 500 */\n readonly retryDelayMs?: number;\n};\n\nexport const DEFAULT_ADMIN_ADDRESS = 'http://127.0.0.1:2019';\n\ntype Target =\n | { readonly kind: 'tcp'; readonly host: string; readonly port: number }\n | { readonly kind: 'unix'; readonly socketPath: string };\n\nconst UNIX = 'unix://';\nconst HOST_PORT = /^(?:[A-Za-z0-9.-]+|\\[[0-9A-Fa-f:.]+\\]):\\d{1,5}$/;\n\nconst refuse = (address: string, why: string): Error =>\n new Error(`Caddy admin address ${JSON.stringify(address)}: ${why}`);\n\nconst isLoopback = (hostname: string): boolean =>\n hostname === 'localhost' || hostname === '[::1]' || /^127(?:\\.\\d{1,3}){3}$/.test(hostname);\n\n/** The dial target and listener for an address, or a refusal naming what is wrong with it. */\nexport const parseAdminAddress = (\n address: string,\n hostHeader?: string,\n): { target: Target; listener: CaddyAdminListener } => {\n if (hostHeader !== undefined && !HOST_PORT.test(hostHeader)) {\n throw refuse(address, `hostHeader ${JSON.stringify(hostHeader)} must be host:port`);\n }\n if (address.startsWith(UNIX)) {\n const socketPath = address.slice(UNIX.length);\n if (!socketPath.startsWith('/')) throw refuse(address, 'the socket path must be absolute');\n return { listener: { kind: 'unix', path: socketPath }, target: { kind: 'unix', socketPath } };\n }\n let url: URL;\n try {\n url = new URL(address);\n } catch {\n throw refuse(address, 'not a URL (http://127.0.0.1:2019 or unix:///path)');\n }\n // β Caddy's local admin endpoint is plaintext by design; its TLS one (`admin.remote`, mTLS on\n // :2021) is a different, network-facing API this transport deliberately does not speak.\n if (url.protocol !== 'http:') throw refuse(address, 'only http:// (loopback) or unix://');\n if (!isLoopback(url.hostname)) {\n throw refuse(address, 'not loopback β the admin API is unauthenticated; forward it to here');\n }\n if (url.pathname !== '/' || url.search !== '' || url.username !== '') {\n throw refuse(address, 'no path, query or credentials β the API lives at the root');\n }\n const port = url.port === '' ? 80 : Number(url.port);\n // β οΈ NOT `url.host`: WHATWG URL drops a default port (`http://127.0.0.1:80` has host\n // `127.0.0.1`), and Caddy compares the Host against `host:port` (admin.go allowedOrigins), so\n // a portless Host is a 403 on every call. The Caddy CLI always sends the port (JoinHostPort).\n const host = hostHeader ?? `${url.hostname}:${String(port)}`;\n return {\n listener: {\n hostHeader: host,\n kind: 'tcp',\n port: Number(host.slice(host.lastIndexOf(':') + 1)),\n },\n // β
`hostname` keeps IPv6 brackets; node:http wants them off.\n target: { host: url.hostname.replace(/^\\[(.*)\\]$/, '$1'), kind: 'tcp', port },\n };\n};\n\nconst code = (cause: unknown): string | undefined =>\n typeof cause === 'object' && cause !== null && 'code' in cause\n ? String((cause as { code: unknown }).code)\n : undefined;\n\n/** Nothing accepted the connection: refused TCP, or no socket file yet. The request never left. */\nconst neverConnected = (cause: unknown): boolean =>\n code(cause) === 'ECONNREFUSED' || code(cause) === 'ENOENT';\n\nconst exchange = (\n target: Target,\n method: string,\n path: string,\n headers: Record<string, string>,\n body: string | undefined,\n timeoutMs: number,\n): Promise<CaddyAdminResponse> =>\n new Promise((resolve, reject) => {\n const bytes = body === undefined ? undefined : Buffer.from(body, 'utf8');\n const request = httpRequest(\n {\n ...(target.kind === 'tcp'\n ? { host: target.host, port: target.port }\n : { socketPath: target.socketPath }),\n headers: bytes === undefined ? headers : { ...headers, 'Content-Length': bytes.length },\n method,\n path,\n timeout: timeoutMs,\n },\n (response) => {\n const chunks: Buffer[] = [];\n response.on('data', (chunk: Buffer) => chunks.push(chunk));\n response.on('error', reject);\n response.on('end', () =>\n resolve({\n body: Buffer.concat(chunks).toString('utf8'),\n status: response.statusCode ?? 0,\n }),\n );\n },\n );\n request.on('timeout', () => request.destroy(new Error(`no answer in ${String(timeoutMs)} ms`)));\n request.on('error', reject);\n request.end(bytes);\n });\n\nexport const localCaddyAdmin = (options: LocalCaddyAdminOptions = {}): CaddyAdmin => {\n const address = options.address ?? DEFAULT_ADMIN_ADDRESS;\n const { listener, target } = parseAdminAddress(address, options.hostHeader);\n const timeoutMs = options.timeoutMs ?? 60_000;\n const retries = options.retries ?? 2;\n const delay = options.retryDelayMs ?? 500;\n const fixed: Record<string, string> =\n listener.kind === 'tcp'\n ? { Host: listener.hostHeader, Origin: `http://${listener.hostHeader}` }\n : { Host: '127.0.0.1' };\n return {\n endpoint: address,\n listener,\n request: async ({ body, headers, method, path }) => {\n for (let attempt = 0; ; attempt += 1) {\n try {\n // oxlint-disable-next-line no-await-in-loop -- sequential by design: retry after a wait\n return await exchange(target, method, path, { ...headers, ...fixed }, body, timeoutMs);\n } catch (cause) {\n if (!neverConnected(cause) || attempt >= retries) {\n const reason = cause instanceof Error ? cause.message : String(cause);\n const message = `Caddy admin ${method} ${path} at ${address}: ${reason}`;\n throw neverConnected(cause) ? new CaddyUnreachableError(message) : new Error(message);\n }\n // oxlint-disable-next-line no-await-in-loop -- the wait between attempts\n await new Promise((resolve) => setTimeout(resolve, delay));\n }\n }\n },\n };\n};\n",
|
|
22
22
|
"/**\n * The Caddy provider with its admin transport, as a single layer for a host stack.\n *\n * Layer.mergeAll(caddyProviders(), launchdProviders(), β¦the stack's other providers)\n *\n * β
launchdProviders() TOO when the stack uses caddyWithFile(): the file is a launchd-subpath\n * HostFile, written through that subpath's HostRunner.\n * β οΈ The default is localCaddyAdmin(): `http://127.0.0.1:2019`, Caddy's default admin listener.\n * Pass another CaddyAdmin for a unix socket, a narrowed `admin { origins }`, or an SSH forward.\n */\nimport * as Layer from 'effect/Layer';\nimport { type CaddyAdmin, caddyAdminLayer } from './admin.ts';\nimport { CaddyConfigProvider } from './config.ts';\nimport { localCaddyAdmin } from './local-admin.ts';\n\nexport const caddyProviders = (admin: CaddyAdmin = localCaddyAdmin()) =>\n CaddyConfigProvider().pipe(Layer.provide(caddyAdminLayer(admin)));\n",
|
|
23
23
|
"/**\n * `caddyWithFile` β one Caddyfile, applied to the running Caddy AND written to the file Caddy\n * starts from, so a restart keeps it (decision 23: \"the file also lands on disk\").\n *\n * β
REUSES launchd's HostFile, NOT A SECOND FILE WRITER. The file is an ordinary `Host.File`\n * (atomic write, digest diff, symlink refusal, `--adopt` for a file already there); the config is\n * a `Caddy.Config` whose `sourceFile` is that HostFile's `path` Output β which is what orders them.\n *\n * β THE ORDER IS FILE, THEN `/load`. The dependency makes Alchemy write the file first, and it is the\n * only order that bootstraps: a new host's Caddy launchd job starts from the file, before any\n * admin API exists to load into. Its cost, and what bounds it:\n * Β· A Caddyfile that does not ADAPT never reaches disk once the config has state: CaddyConfig's\n * diff adapts it at PLAN time and fails the plan. (On the very first deploy the file's path is\n * still an Output, Alchemy skips the adoption probe, and the check runs at apply instead.)\n * Β· A Caddyfile that adapts but that Caddy then REFUSES (a port in use, a missing cert file) is\n * on disk when `/load` fails. Caddy keeps serving the old config, the deploy fails with Caddy's\n * reason β and a Caddy started without `--resume` would load the refused file on its next\n * restart. Managed Caddies run with `--resume` (decision, 2026-09-21; docs/caddy.md): they\n * restart from `autosave.json`, the last config Caddy ACCEPTED, and the file is only the\n * first-boot fallback.\n * Β· The skipped probe is also why the ADOPTION check of a first deploy happens at apply: a Caddy\n * running a config this stack did not load is refused before `/load` unless the deploy runs\n * with `--adopt` (config-lifecycle.ts reconcileConfig) β after this file is written.\n * β SECRETS ARE REFUSED HERE, AT DECLARATION, before the HostFile exists: its `content` is a prop\n * too, and would carry a literal into state even if the CaddyConfig then refused it.\n * β
BOTH RETAIN. Removing this from a stack must not delete the file a restart needs.\n */\nimport * as Effect from 'effect/Effect';\nimport * as RemovalPolicy from 'alchemy/RemovalPolicy';\nimport { HostFile } from '../launchd/host-file.ts';\nimport { CaddyConfig } from './config.ts';\nimport { configProblems } from './config-form.ts';\n\nexport interface CaddyWithFileProps {\n /** The whole Caddyfile. β Never a secret β use `{env.NAME}` / `{file./path}` placeholders. */\n readonly caddyfile: string;\n /** Absolute path Caddy is started with: `caddy run --config <path> --adapter caddyfile`. */\n readonly path: string;\n /** @default 0o644 */\n readonly mode?: number;\n readonly owner?: string | number;\n readonly group?: string | number;\n}\n\n/**\n * Declare the file (`<id>-file`) and the config (`<id>`). Provide `launchdProviders()` for the\n * file and `caddyProviders()` for the config.\n *\n * const { config, file } = yield* caddyWithFile('caddy', { caddyfile, path: '/usr/local/etc/Caddyfile' });\n */\nexport const caddyWithFile = (id: string, props: CaddyWithFileProps) =>\n Effect.gen(function* () {\n const found = configProblems({ caddyfile: props.caddyfile, sourceFile: props.path });\n if (found.length > 0) {\n return yield* Effect.fail(new Error(`caddyWithFile ${id}: ${found.join('; ')}`));\n }\n const file = yield* HostFile(`${id}-file`, {\n content: props.caddyfile,\n mode: props.mode ?? 0o644,\n path: props.path,\n ...(props.owner === undefined ? {} : { owner: props.owner }),\n ...(props.group === undefined ? {} : { group: props.group }),\n }).pipe(RemovalPolicy.retain());\n const config = yield* CaddyConfig(id, { caddyfile: props.caddyfile, sourceFile: file.path });\n return { config, file };\n });\n",
|
|
24
24
|
"/**\n * `Host.File` β one text file on the host, declared: content, mode, owner, group.\n *\n * - create / update β atomic write (temp file in the same directory, mode and owner set, rename),\n * skipped when the file already matches; then read back and compared.\n * - read β lstat plus the SHA-256 of the bytes.\n * - diff β declared SHA-256 against the stored and on-disk digest, plus mode and owner.\n * - replace β only when `path` changes; create-before-delete.\n * - delete β removes the file.\n *\n * β NEVER A SECRET β see host-file-form.ts. Secret files stay rendered by openbao-agent.\n * β οΈ NOTHING IS ADOPTED WITHOUT `--adopt`: a file already at the path reads as `Unowned`, and where\n * the plan never asked, reconcile refuses it unless adoption is on (docs/ownership.md).\n * β
IT LIVES IN THE launchd SUBPATH because the two travel together: a Mac host stack declares a\n * daemon's config file and the daemon that reads it, through the same HostRunner.\n */\nimport { Resource } from 'alchemy';\nimport { Unowned } from 'alchemy/AdoptPolicy';\nimport { isResolved } from 'alchemy/Diff';\nimport * as Provider from 'alchemy/Provider';\nimport * as Effect from 'effect/Effect';\nimport { adoptsAtApply } from '../ownership/adopt.ts';\nimport { noteUnfinished } from '../ownership/resume.ts';\nimport { lift, resolvedString } from './host-effect.ts';\nimport type { HostFileAttributes, HostFileProps } from './host-file-form.ts';\nimport { deleteFile, diffFile, readFileAttributes, reconcileFile } from './host-file-lifecycle.ts';\nimport { HostRunnerService } from './runner.ts';\n\nexport type { HostFileAttributes, HostFileProps } from './host-file-form.ts';\n\nexport interface HostFile extends Resource<'Host.File', HostFileProps, HostFileAttributes> {}\n\nexport const HostFile = Resource<HostFile>('Host.File');\n\nexport const HostFileProvider = () =>\n Provider.effect(\n HostFile,\n Effect.gen(function* () {\n const runner = yield* HostRunnerService;\n return HostFile.Provider.of({\n list: () => Effect.succeed([]),\n\n read: ({ olds, output }) =>\n lift(async () => {\n const found = await readFileAttributes(runner, olds.path);\n if (found === undefined) return undefined;\n return output === undefined ? Unowned(found) : found;\n }),\n\n diff: ({ instanceId, news, output }) => {\n // β
An unfinished generation of our own: `--adopt` may resume it (ownership/resume.ts).\n if (output === undefined) return noteUnfinished(instanceId);\n if (isResolved(news)) return lift(() => diffFile(runner, news, output));\n // β A new path is a replace even while `content` is unresolved (an Output templated in):\n // the engine's default would be an update, which writes the new path and never\n // removes the old one. reconcileFile also defends, for a path that is an Output.\n const path = resolvedString(news, 'path');\n return Effect.succeed(\n path !== undefined && path !== output.path ? { action: 'replace' as const } : undefined,\n );\n },\n\n // β
`--adopt` reaches the apply too: the probe never ran for a create with an Output prop.\n // β A create or an unfinished generation β never a fresh replace's, never offered it.\n reconcile: ({ fqn, instanceId, news, output }) =>\n Effect.flatMap(adoptsAtApply({ fqn, instanceId, output }), (adopt) =>\n lift(() => reconcileFile(runner, news, output, adopt)),\n ),\n\n delete: ({ output }) => lift(() => deleteFile(runner, output)),\n });\n }),\n );\n",
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* β THE SDK WAS ALREADY IN THE STORE AND DECLARED BY NOBODY. Measured 2026-09-15:
|
|
6
6
|
* `node_modules/.pnpm/cloudflare@4.5.0` exists as a transitive dependency of `alchemy`, and
|
|
7
7
|
* `pnpm ls cloudflare --depth 0` at the root and in every workspace package returns nothing.
|
|
8
|
-
*
|
|
8
|
+
* `<estate>/local/scripts/vendor-sdk-ratchet.ts` row R2 says exactly that β "not declared anywhere
|
|
9
9
|
* in the workspace yet β¦ the row adds the dependency once, then converts the TS callers" β so
|
|
10
10
|
* this package is that row's first half, and the twenty ratcheted offences keep their rows.
|
|
11
11
|
*
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
"sources": ["../src/cloudflare/r2-bucket-lock.ts", "../src/cloudflare/client.ts", "../src/cloudflare/lock-rules.ts", "../src/cloudflare/mesh-node.ts", "../src/cloudflare/mesh-node-api.ts", "../src/cloudflare/mesh-node-form.ts", "../src/cloudflare/mesh-node-lifecycle.ts", "../src/cloudflare/mesh-node-token.ts", "../src/cloudflare/providers.ts", "../src/cloudflare/website.ts"],
|
|
4
4
|
"sourcesContent": [
|
|
5
5
|
"import { Unowned } from 'alchemy/AdoptPolicy';\nimport * as Provider from 'alchemy/Provider';\nimport { Resource, type ResourceClass } from 'alchemy/Resource';\n/**\n * `Cloudflare.R2BucketLock` β the lock rule set on one R2 bucket, declared rather than applied by\n * hand. A bucket lock is a RETENTION FLOOR: while a rule covers an object, no API call, no\n * lifecycle rule and no credential can delete it.\n *\n * π΄ WHY THIS EXISTS AT ALL, AND WHAT IT REPLACES. a consuming stack shipped the retention floor as a\n * documented `wrangler r2 bucket lock add β¦` step in the runbook, on the measured ground that\n * `alchemy@2.0.0-beta.77` has no lock property anywhere in its R2 namespace. That measurement is\n * still true and it was the wrong conclusion: a step a human runs once is a step an Alchemy plan\n * can never show missing, so removing the floor by hand leaves that stack, its plan and\n * every gate completely green. The operator's rule (2026-09-15) is to cover such a gap with a\n * custom resource over the vendor's SDK, which is what this is.\n *\n * β THE SDK ALREADY HAD IT, AND THAT IS WORTH SAYING BECAUSE IT DECIDED THE SHAPE. Measured\n * 2026-09-15 in `cloudflare@4.5.0`, `resources/r2/buckets/locks.d.ts`:\n *\n * update(bucketName: string, params: LockUpdateParams, options?): APIPromise<β¦>\n * get(bucketName: string, params: LockGetParams, options?): APIPromise<LockGetResponse>\n *\n * over `PUT`/`GET /accounts/{account_id}/r2/buckets/{bucket_name}/lock` with the jurisdiction\n * carried as the `cf-r2-jurisdiction` header (`locks.mjs:17` and `:41`). So there is no raw\n * `client.put` fallback here and no path string this package invented β the fallback the rule\n * allows was not needed.\n *\n * β οΈ THE RULE SET IS A REPLACE, NOT A MERGE. `PUT` takes the WHOLE list, exactly like R2's\n * lifecycle rules β which is the trap a consuming stack records for\n * `homeflare-git`. Declaring this resource on a bucket whose live rules nobody has read would\n * drop them, so `read` fetches them first and `reconcile` refuses to write when they already\n * match.\n */\nimport type Cloudflare from 'cloudflare';\nimport { NotFoundError } from 'cloudflare';\nimport * as Effect from 'effect/Effect';\nimport { CloudflareApi } from './client.ts';\nimport {\n type Jurisdiction,\n type R2LockRule,\n jurisdictionOf,\n rulesEqual,\n toBody,\n} from './lock-rules.ts';\nimport type { Providers } from './providers.ts';\n\nexport interface R2BucketLockProps {\n readonly accountId: string;\n readonly bucketName: string;\n /** β οΈ Part of the bucket's IDENTITY, not a preference β a bucket in another jurisdiction is another bucket. */\n readonly jurisdiction?: Jurisdiction;\n /**\n * β REQUIRED, AND ITS EMPTY VALUE IS THE DANGEROUS ONE. `rules: []` means NO FLOOR: it is what\n * `delete` sends to take a lock off deliberately. An optional property would let a declaration\n * that simply forgot it send `[]` and silently unlock the repository, reporting success β the\n * same reasoning `LiteLLM.McpServer.allowedTools` is required for.\n */\n readonly rules: readonly R2LockRule[];\n}\n\nexport type R2BucketLockAttributes = {\n readonly bucketName: string;\n readonly jurisdiction: Jurisdiction;\n readonly rules: readonly R2LockRule[];\n};\n\nexport interface R2BucketLock extends Resource<\n 'Cloudflare.R2BucketLock',\n R2BucketLockProps,\n R2BucketLockAttributes,\n never,\n Providers\n> {}\n\n// β οΈ EXPLICIT ANNOTATION, REQUIRED BY isolatedDeclarations. This file was internal to a\n// monorepo before it was published; a published package emits its .d.ts from a separate\n// tsc pass, so every exported symbol must state its type rather than infer it.\nexport const R2BucketLock: ResourceClass<R2BucketLock> = Resource<R2BucketLock>(\n 'Cloudflare.R2BucketLock',\n {\n /**\n * β RETAIN, AND ON THIS RESOURCE IT IS NOT THE ESTATE'S HABIT BUT THE WHOLE POINT. A lock is a\n * safety floor: removing the declaration must not remove the floor, because the state it\n * protects is the only physical backup of the estate's one Postgres. `delete` below is\n * implemented β a provider that cannot delete cannot be tested or corrected β and it is\n * reachable only by an operator running the CLI directly, past a deploy wrapper.\n */\n defaultRemovalPolicy: 'retain',\n },\n);\n\n/**\n * π΄ A 404 HERE IS \"NOTHING TO ADOPT\", AND THE FIRST PLAN PROVED IT IS THE ORDINARY CASE.\n * Measured 2026-09-15, an alchemy plan --detailed on a bucket that does not\n * exist yet: `404 {\"success\":false,\"errors\":[{\"code\":10006,\"message\":\"The specified bucket does\n * not exist.\"}]}` β raised from `read`, during PLANNING, before anything is created. Alchemy\n * calls `read` for a resource with no prior state, so on every first deploy the lock is asked\n * about a bucket the same plan is about to create. Letting that throw makes the resource\n * impossible to plan; swallowing it with `catchAll` would also hide a 403 from a mis-scoped\n * token, which is the failure this estate spends the most time on. So: 404 β absent, everything\n * else propagates.\n *\n * β AN EMPTY LIVE RULE SET IS \"NO LOCK\", NOT \"A LOCK WITH NOTHING IN IT\", so it reads as absent\n * and the resource CREATES. The API answers `{}` for a bucket that has never been locked and\n * `{ rules: [] }` for one whose rules were removed; both mean the floor is gone.\n * β οΈ `Unowned` UNTIL THIS STACK HAS WRITTEN IT. A lock someone applied by hand β which is exactly\n * what the retired runbook step did β must not be silently rewritten by a mistyped declaration;\n * the planner refuses until the resource is piped through `adopt(true)`, which is one line an\n * operator reads in the diff. An adopted rule set that already MATCHES then costs no request at\n * all, because `reconcile` below returns before writing.\n */\nexport const readLock = (\n client: Cloudflare,\n props: { accountId: string; bucketName: string; jurisdiction?: Jurisdiction },\n written: boolean,\n): Effect.Effect<R2BucketLockAttributes | undefined> =>\n Effect.gen(function* () {\n const jurisdiction = jurisdictionOf(props);\n const live = yield* Effect.tryPromise({\n try: () =>\n client.r2.buckets.locks.get(props.bucketName, {\n account_id: props.accountId,\n jurisdiction,\n }),\n catch: (cause) => cause,\n }).pipe(\n Effect.catchIf(\n (cause) => cause instanceof NotFoundError,\n () => Effect.succeed(undefined),\n ),\n Effect.orDie,\n );\n if (live === undefined) return undefined;\n const rules = (live.rules ?? []) as readonly R2LockRule[];\n if (rules.length === 0) return undefined;\n const attributes: R2BucketLockAttributes = {\n bucketName: props.bucketName,\n jurisdiction,\n rules,\n };\n return written ? attributes : Unowned(attributes);\n });\n\n/**\n * β A NO-OP MAKES NO REQUEST, AND ON A LOCK THAT MATTERS MORE THAN TIDINESS. `PUT` replaces the\n * rule set; re-sending an identical one on every deploy is a write against the only thing\n * standing between this repository and a delete, for no change. `output` is what the last deploy\n * persisted (or what `read` just adopted), so the comparison costs nothing either.\n */\nexport const reconcileLock = (\n client: Cloudflare,\n news: R2BucketLockProps,\n output: R2BucketLockAttributes | undefined,\n): Effect.Effect<R2BucketLockAttributes> =>\n Effect.gen(function* () {\n const jurisdiction = jurisdictionOf(news);\n if (output !== undefined && rulesEqual(output.rules, news.rules)) return output;\n yield* Effect.promise(() =>\n client.r2.buckets.locks.update(news.bucketName, {\n account_id: news.accountId,\n jurisdiction,\n ...toBody(news.rules),\n }),\n );\n return { bucketName: news.bucketName, jurisdiction, rules: news.rules };\n });\n\n/**\n * β DELETING THIS RESOURCE MEANS PUTTING AN EMPTY RULE SET, WHICH IS UNLOCKING THE BUCKET. It is\n * implemented because the retirement path is real β R2 refuses to empty a bucket while any lock\n * rule is configured, so retiring the repository has to start here β and it is behind\n * `defaultRemovalPolicy: 'retain'` and the estate CLI's refusal of `destroy` precisely because\n * no ordinary deploy should ever reach it.\n */\nexport const deleteLock = (\n client: Cloudflare,\n output: R2BucketLockAttributes,\n accountId: string,\n): Effect.Effect<void> =>\n Effect.gen(function* () {\n yield* Effect.promise(() =>\n client.r2.buckets.locks.update(output.bucketName, {\n account_id: accountId,\n jurisdiction: output.jurisdiction,\n rules: [],\n }),\n );\n });\n\nexport const R2BucketLockProvider = () =>\n Provider.effect(\n R2BucketLock,\n Effect.gen(function* () {\n const client = yield* CloudflareApi;\n return R2BucketLock.Provider.of({\n /**\n * β EMPTY, AND ALCHEMY'S OWN CONTRACT ASKS FOR EXACTLY THAT. `ProviderService.list`:\n * \"Resources with no native enumeration API (β¦ sub-resources keyed entirely by a parent)\n * should return an empty array rather than throwing.\" There is no list-locks endpoint β\n * a lock is reachable only through the bucket that owns it β so enumerating would mean\n * listing every bucket in the account and GETting each one's lock.\n * ββ AND `nuke.singleton` IS WHY THAT IS NOT MERELY UNAVAILABLE BUT UNWANTED. `list`\n * feeds `alchemy unsafe nuke`, which lists and then deletes; `delete` here is an UNLOCK.\n * A working enumeration would hand one command the ability to strip the retention floor\n * off every bucket in the account β the precise event a floor exists to make impossible.\n * `singleton` is the documented word for it: always-present configuration whose delete\n * resets rather than removes.\n */\n list: () => Effect.succeed([]),\n nuke: { singleton: true },\n read: Effect.fn(function* ({ olds, output }) {\n const props = olds ?? undefined;\n if (props === undefined) return undefined;\n return yield* readLock(client, props, output !== undefined);\n }),\n reconcile: Effect.fn(({ news, output }) => reconcileLock(client, news, output)),\n delete: Effect.fn(({ olds, output }) => deleteLock(client, output, olds.accountId)),\n });\n }),\n );\n",
|
|
6
|
-
"/**\n * The Cloudflare REST API as an Effect service, over `cloudflare-typescript`. Codex standard 5:\n * the vendor's SDK, never a hand-rolled client β and never a hand-rolled `fetch` either.\n *\n * β THE SDK WAS ALREADY IN THE STORE AND DECLARED BY NOBODY. Measured 2026-09-15:\n * `node_modules/.pnpm/cloudflare@4.5.0` exists as a transitive dependency of `alchemy`, and\n * `pnpm ls cloudflare --depth 0` at the root and in every workspace package returns nothing.\n *
|
|
7
|
-
"/**\n * The vocabulary of an R2 lock rule, and the comparison that decides whether to write one.\n *\n * β
ITS OWN MODULE FOR THE REASON
|
|
6
|
+
"/**\n * The Cloudflare REST API as an Effect service, over `cloudflare-typescript`. Codex standard 5:\n * the vendor's SDK, never a hand-rolled client β and never a hand-rolled `fetch` either.\n *\n * β THE SDK WAS ALREADY IN THE STORE AND DECLARED BY NOBODY. Measured 2026-09-15:\n * `node_modules/.pnpm/cloudflare@4.5.0` exists as a transitive dependency of `alchemy`, and\n * `pnpm ls cloudflare --depth 0` at the root and in every workspace package returns nothing.\n * `<estate>/local/scripts/vendor-sdk-ratchet.ts` row R2 says exactly that β \"not declared anywhere\n * in the workspace yet β¦ the row adds the dependency once, then converts the TS callers\" β so\n * this package is that row's first half, and the twenty ratcheted offences keep their rows.\n *\n * β AND IT IS PINNED EXACTLY, NOT `catalog:`. There is no catalog entry for `cloudflare`, and the\n * version that resolves today is the one alchemy pulls in. Pinning `4.5.0` means a future\n * alchemy bump moves alchemy's copy and not this package's contract β the alternative is a\n * provider whose request shape changes because an unrelated dependency was upgraded.\n *\n * β οΈ `fetch` IS A SEAM, NOT A CONVENIENCE β the same argument `packages/litellm-client` makes. The\n * SDK hands its custom fetch the URL and init it built, so a test double conforming to\n * `Core.Fetch` exercises the real path assembly, the real body serialisation and the real header\n * set. A test that stubbed `client.r2.buckets.locks.update` would prove only that a stub was\n * called, which is precisely the class of check this estate keeps finding green over nothing.\n */\nimport Cloudflare from 'cloudflare';\nimport * as Config from 'effect/Config';\nimport * as Context from 'effect/Context';\nimport * as Effect from 'effect/Effect';\nimport * as Layer from 'effect/Layer';\nimport * as Redacted from 'effect/Redacted';\n\n/** What the SDK accepts for `fetch`, restated so tests can name it without importing SDK internals. */\nexport type ClientFetch = (url: string | URL | Request, init?: RequestInit) => Promise<Response>;\n\nexport class CloudflareApi extends Context.Service<CloudflareApi, Cloudflare>()(\n 'homeflare/CloudflareApi',\n) {}\n\n/**\n * β οΈ THE FAKE FETCH IS THE ONLY REASON THIS TAKES OPTIONS AT ALL. Everything else the SDK reads\n * from the environment the estate CLI already populates.\n */\nexport const makeClient = (options: { apiToken: string; fetch?: ClientFetch }): Cloudflare =>\n new Cloudflare({\n apiToken: options.apiToken,\n ...(options.fetch === undefined ? {} : { fetch: options.fetch }),\n });\n\n/**\n * β FAIL CLOSED ON THE VALUE, NEVER ON THE VARIABLE EXISTING. AGENTS.md \"the four that cost the\n * most\" Β§4: a denied OpenBao path renders the template EMPTY rather than erroring, and every\n * Cloudflare call then answers 403 exactly like a revoked token β which sends the operator to\n * Cloudflare to rotate a credential that was fine. Your deploy wrapper should write\n * CLOUDFLARE_API_TOKEN from the stack's declared role, short-lived and scoped.\n */\nconst MISSING_TOKEN =\n 'CLOUDFLARE_API_TOKEN is not set. Mint a short-lived, scoped token and export it before ' +\n 'role named in estate.stack.ts; running `alchemy` directly skips that and there is no default.';\n\nconst EMPTY_TOKEN =\n 'CLOUDFLARE_API_TOKEN is set but EMPTY. That is a denied or missing OpenBao grant on the ' +\n 'stackβs role, not a rotated token β re-mint it rather than rotating anything at Cloudflare.';\n\n/**\n * β A LAYER'S ERROR CHANNEL MUST BE `never`, so a ConfigError becomes a defect carrying a sentence\n * rather than a type error pushed up into `Stack`. Same decision, same words, as `LiteLLMLive`.\n */\nexport const CloudflareApiLive = Layer.effect(\n CloudflareApi,\n Effect.gen(function* () {\n const token = yield* Config.Redacted('CLOUDFLARE_API_TOKEN').pipe(\n Effect.catchTag('ConfigError', () => Effect.die(new Error(MISSING_TOKEN))),\n );\n const value = Redacted.value(token);\n if (value.length === 0) return yield* Effect.die(new Error(EMPTY_TOKEN));\n return makeClient({ apiToken: value });\n }),\n);\n",
|
|
7
|
+
"/**\n * The vocabulary of an R2 lock rule, and the comparison that decides whether to write one.\n *\n * β
ITS OWN MODULE FOR THE REASON `<estate>/litellm/src/equal.ts` IS ONE: the comparison is the\n * riskiest function in the provider and it deserves to be readable and testable without the\n * lifecycle around it. An equality that returned true for everything would make every reconcile\n * a no-op and the resource would silently never write anything β which looks exactly like a\n * converged stack.\n */\n/** Jurisdictions the API accepts, as the SDK declares them. */\nexport type Jurisdiction = 'default' | 'eu' | 'fedramp';\n\n/**\n * One lock rule. β οΈ `prefix` OMITTED MEANS THE WHOLE BUCKET β the SDK documents an empty prefix as\n * the way to scope a rule to every object, and omitting the key is how that is spelled on the wire.\n */\nexport type R2LockRule = {\n readonly id: string;\n readonly enabled: boolean;\n readonly prefix?: string;\n readonly condition:\n | { readonly type: 'Age'; readonly maxAgeSeconds: number }\n | { readonly type: 'Date'; readonly date: string }\n | { readonly type: 'Indefinite' };\n};\n\n/** β οΈ `undefined` and `'default'` are the same jurisdiction; normalise so a diff never sees both. */\nexport const jurisdictionOf = (props: { readonly jurisdiction?: Jurisdiction }): Jurisdiction =>\n props.jurisdiction ?? 'default';\n\n/**\n * β FIELD BY FIELD, IN ORDER, AND NOT `JSON.stringify` OF EITHER SIDE. The API returns rules in\n * its own key order and omits `prefix` where this repo may spell it `''`; a stringify compare\n * would report drift on every plan and PUT an identical body each deploy. Order across the list\n * IS compared, because the rule set is a replace and the list is what gets sent.\n */\nexport function rulesEqual(a: readonly R2LockRule[], b: readonly R2LockRule[]): boolean {\n if (a.length !== b.length) return false;\n return a.every((left, index) => {\n const right = b[index];\n if (right === undefined) return false;\n if (left.id !== right.id || left.enabled !== right.enabled) return false;\n if ((left.prefix ?? '') !== (right.prefix ?? '')) return false;\n const [lc, rc] = [left.condition, right.condition];\n if (lc.type !== rc.type) return false;\n if (lc.type === 'Age') return rc.type === 'Age' && lc.maxAgeSeconds === rc.maxAgeSeconds;\n if (lc.type === 'Date') return rc.type === 'Date' && lc.date === rc.date;\n return true;\n });\n}\n\n/**\n * The body the SDK sends. Split out so the test can assert the wire shape against one function.\n *\n * β οΈ IT TAKES THE RULES, NOT THE PROPS, so this module names nothing from the resource β the\n * comparison and the vocabulary stay readable without the lifecycle around them.\n */\nexport const toBody = (rules: readonly R2LockRule[]): { rules: R2LockRule[] } => ({\n rules: rules.map((rule) => ({ ...rule })),\n});\n",
|
|
8
8
|
"/**\n * `Cloudflare.MeshNode` β a Cloudflare Mesh node (a `warp_connector` object), declared without\n * its token ever touching Alchemy state. Usage and the enrolment step: docs/mesh-node.md.\n *\n * π΄ WHY THIS EXISTS ALONGSIDE `Cloudflare.Tunnel.WarpConnector`. A Mesh node that fronts a\n * private service needs a node record in the graph, so the Gateway rules and DNS records that\n * name it deploy together. Alchemy beta.79's WarpConnector would put the node token β the\n * credential that lets any Linux host join the account's Mesh as that node β into plaintext\n * state on every read, and it cannot create an HA node. See mesh-node-form.ts.\n *\n * β
REMOVAL POLICY: `retain` BY DEFAULT β see the β
on `MeshNode` below, and what it does to an\n * `ha` replace in mesh-node-lifecycle.ts.\n */\nimport { Credentials } from '@distilled.cloud/cloudflare/Credentials';\nimport * as Cloudflare from 'alchemy/Cloudflare';\nimport { isResolved } from 'alchemy/Diff';\nimport * as Provider from 'alchemy/Provider';\nimport { Resource, type ResourceClass } from 'alchemy/Resource';\nimport * as Context from 'effect/Context';\nimport * as Effect from 'effect/Effect';\nimport * as HttpClient from 'effect/unstable/http/HttpClient';\nimport {\n type MeshNodeAttributes,\n type MeshNodeProps,\n diffMeshNode,\n validateMeshNode,\n} from './mesh-node-form.ts';\nimport { deleteMeshNode, readMeshNode, reconcileMeshNode } from './mesh-node-lifecycle.ts';\nimport type { Providers } from './providers.ts';\n\nexport interface MeshNode extends Resource<\n 'Cloudflare.MeshNode',\n MeshNodeProps,\n MeshNodeAttributes,\n never,\n Providers\n> {}\n\n/**\n * β
`defaultRemovalPolicy: 'retain'`, LIKE EVERY KIT RESOURCE WHOSE DELETION BREAKS ITS CONSUMERS\n * (the `Bao.*` families, `R2BucketLock`, the Proxmox storage families; the reasoning is written\n * once in proxmox/resource.ts). Deleting a node cuts every enrolled replica off the Mesh, and\n * nothing brings it back: a new node has a new id, a new token and a new Mesh IP per replica, so\n * each host must be re-enrolled by hand and every Gateway rule or allow-list that pins the old\n * Mesh IP breaks. So dropping the declaration, or `alchemy destroy`, leaves the node live.\n * β οΈ IT ALSO SKIPS THE OLD NODE'S DELETE IN A REPLACE (Apply.ts, `deleteOldGenerations` and the\n * GC's `retainOldGeneration`, beta.79). A same-name `ha` change therefore REFUSES, deleting\n * nothing, until that deploy opts in with `.pipe(RemovalPolicy.destroy())`; a create-first\n * replace leaves the old node live and unmanaged. Both measured in mesh-node-policy.test.ts.\n * β `delete` STAYS FULLY IMPLEMENTED. Retain is Terraform's `prevent_destroy`, not a stub: it runs\n * the moment a declaration opts in with `.pipe(RemovalPolicy.destroy())`.\n */\nexport const MeshNode: ResourceClass<MeshNode> = Resource<MeshNode>('Cloudflare.MeshNode', {\n defaultRemovalPolicy: 'retain',\n});\n\n/** What every call below needs: the account, credentials and HTTP β Alchemy's own trio. */\nexport type MeshNodeServices =\n | Cloudflare.CloudflareEnvironment\n | Credentials\n | HttpClient.HttpClient;\n\n/** The account id, resolved the way every `alchemy/Cloudflare` provider resolves it. */\nconst accountId = Effect.gen(function* () {\n const resolved = yield* yield* Cloudflare.CloudflareEnvironment;\n return resolved.accountId;\n});\n\n/**\n * β
`Provider.effect`, CAPTURING THE THREE SERVICES AT BUILD. `Provider.succeed` (what Alchemy's\n * own providers use) leaves each handler to find CloudflareEnvironment and Credentials in the\n * engine's context at call time β which works for `Cloudflare.providers()` only because it\n * `provideMerge`s them into the stack. Capturing them here keeps this provider correct whether\n * or not the stack also merges `Cloudflare.providers()`.\n */\nexport const MeshNodeProvider = () =>\n Provider.effect(\n MeshNode,\n Effect.gen(function* () {\n const services = Context.make(\n Cloudflare.CloudflareEnvironment,\n yield* Cloudflare.CloudflareEnvironment,\n ).pipe(\n Context.add(Credentials, yield* Credentials),\n Context.add(HttpClient.HttpClient, yield* HttpClient.HttpClient),\n );\n const run = <A, E>(effect: Effect.Effect<A, E, MeshNodeServices>) =>\n Effect.provideContext(effect, services);\n\n return MeshNode.Provider.of({\n stables: ['id', 'accountId', 'ha'],\n /**\n * β EMPTY, AND `nuke.skip`. Alchemy's own `Cloudflare.Tunnel.WarpConnector` already lists\n * every `warp_connector` in the account for `alchemy unsafe nuke`; a second listing here\n * would make nuke delete each node twice. `read` does not depend on this.\n */\n list: () => Effect.succeed([]),\n nuke: { skip: true },\n /**\n * β VALIDATE AT PLAN TIME, NOT ONLY IN `reconcile`. An `ha` replace is delete-first: the\n * engine deletes the old node BEFORE the new generation's reconcile runs, so a\n * declaration refused there would leave no node at all. launchd's job-preflight.ts\n * refuses at plan time for the same reason.\n */\n diff: ({ news, output }) =>\n run(\n Effect.gen(function* () {\n const invalid = isResolved(news) ? validateMeshNode(news) : undefined;\n if (invalid !== undefined) return yield* Effect.fail(invalid);\n return diffMeshNode(news, output, yield* accountId);\n }),\n ),\n read: ({ olds, output }) =>\n run(Effect.flatMap(accountId, (account) => readMeshNode(account, olds, output))),\n reconcile: ({ news, output }) =>\n run(Effect.flatMap(accountId, (account) => reconcileMeshNode(account, news, output))),\n delete: ({ output }) => run(deleteMeshNode(output)),\n });\n }),\n );\n",
|
|
9
9
|
"/**\n * The `warp_connector` calls a Cloudflare Mesh node needs, and nothing else.\n *\n * β
A MESH NODE IS A `warp_connector` OBJECT. Cloudflare's Mesh docs create one with\n * `POST /accounts/{account_id}/warp_connector` and fetch its enrolment token from\n * `GET β¦/warp_connector/{id}/token` (developers.cloudflare.com/mesh/get-started, read\n * 2026-09-21). There is no separate \"mesh node\" endpoint.\n *\n * β
OVER `@distilled.cloud/cloudflare`, THE SDK ALCHEMY'S OWN CLOUDFLARE PROVIDERS USE, not the\n * `cloudflare` npm SDK the R2 lock uses. Measured 2026-09-21: `cloudflare@4.5.0`'s\n * `WARPConnectorCreateParams` has only `account_id` and `name`, so it cannot create an HA node.\n * distilled `1.0.0-rc.12` (the version alchemy@2.0.0-beta.79 pins) has `ha?: boolean` on\n * `CreateTunnelWarpConnectorRequest`, typed errors (`TunnelNotFound` code 1002 / 404,\n * `DuplicateTunnelName` code 1013), and resolves credentials and the account exactly the way\n * `Cloudflare.Tunnel.WarpConnector` and `Cloudflare.Gateway.Rule` do β so the node and the Gateway\n * rules that pin its Mesh IP always land in the same account.\n *\n * β NO FUNCTION IN THIS FILE READS THE NODE TOKEN, and `observe` copies named fields only. The\n * HA docs say the create response carries a `token` field; distilled's decoder copies only the\n * fields in its schema (protocol.ts, `getProps(outputAst)`), and `observe` below would drop it\n * again if it ever got through. The one token read lives in mesh-node-token.ts, on demand.\n */\nimport * as zeroTrust from '@distilled.cloud/cloudflare/zero-trust';\nimport * as Data from 'effect/Data';\nimport * as Effect from 'effect/Effect';\nimport * as Stream from 'effect/Stream';\n\n/** `inactive` (never run), `degraded`, `healthy` or `down` β Cloudflare's own four. */\nexport type MeshNodeStatus = 'inactive' | 'degraded' | 'healthy' | 'down';\n\n/** A live node as this package sees it. β There is no token field here, on purpose. */\nexport interface ObservedNode {\n readonly id: string;\n readonly name: string;\n readonly status: MeshNodeStatus | undefined;\n}\n\n/** A refusal or failure, carrying the sentence an operator needs. Never a token. */\nexport class MeshNodeError extends Data.TaggedError('MeshNodeError')<{\n readonly message: string;\n}> {}\n\ntype Raw = {\n readonly id?: string | null;\n readonly name?: string | null;\n readonly status?: string | null;\n readonly deletedAt?: string | null;\n};\n\nconst STATUSES: readonly string[] = ['inactive', 'degraded', 'healthy', 'down'];\n\n/**\n * β οΈ A DELETED NODE IS NOT A NODE. Cloudflare keeps deleted tunnels (every response carries\n * `deleted_at`, and the list takes `is_deleted`), so a `GET` by id can answer for one. Treating\n * that as live would \"adopt\" a tombstone and PATCH it.\n */\nexport const observe = (raw: Raw): ObservedNode | undefined => {\n if (typeof raw.id !== 'string' || raw.id.length === 0) return undefined;\n if (raw.deletedAt !== undefined && raw.deletedAt !== null) return undefined;\n const status =\n typeof raw.status === 'string' && STATUSES.includes(raw.status)\n ? (raw.status as MeshNodeStatus)\n : undefined;\n return { id: raw.id, name: raw.name ?? '', status };\n};\n\n/** The node with this id, or `undefined` when it is gone (404 / code 1002, or soft-deleted). */\nexport const getNode = (accountId: string, nodeId: string) =>\n zeroTrust.getTunnelWarpConnector({ accountId, tunnelId: nodeId }).pipe(\n Effect.map(observe),\n Effect.catchTag('TunnelNotFound', () => Effect.succeed(undefined)),\n );\n\n/**\n * The live node with exactly this name, or `undefined`.\n *\n * β
`name` is also sent as the list filter, but the match is re-checked here: the filter's\n * exactness is not documented, and a prefix match would adopt the wrong node.\n * β Two live matches is refused rather than resolved. Names are unique per account (the create\n * answers code 1013 otherwise), so two means something this package does not understand.\n */\nexport const findNodeByName = (accountId: string, name: string) =>\n zeroTrust.listTunnelWarpConnectors.items({ accountId, name, isDeleted: false }).pipe(\n Stream.map(observe),\n Stream.filter((node): node is ObservedNode => node !== undefined && node.name === name),\n Stream.runCollect,\n Effect.flatMap((chunk) => {\n const matches = Array.from(chunk);\n if (matches.length <= 1) return Effect.succeed(matches[0]);\n const ids = matches.map((node) => node.id).join(', ');\n return Effect.fail(\n new MeshNodeError({\n message: `Mesh node name \"${name}\" matches ${matches.length} live nodes (${ids}). Names are unique per account, so refusing to guess which one is meant.`,\n }),\n );\n }),\n );\n\n/**\n * β `ha` IS SENT ON CREATE AND NOWHERE ELSE. Cloudflare's HA page: \"High availability is set at\n * node creation time and cannot be changed afterward.\" The PATCH body (`PatchTunnelWarpConnector\n * Request`) has only `name` and `tunnelSecret`.\n */\nexport const createNode = (accountId: string, name: string, ha: boolean) =>\n zeroTrust.createTunnelWarpConnector({ accountId, name, ha }).pipe(\n Effect.flatMap((raw) => {\n const node = observe(raw);\n return node === undefined\n ? Effect.fail(\n new MeshNodeError({ message: `Creating Mesh node \"${name}\" returned no id.` }),\n )\n : Effect.succeed(node);\n }),\n );\n\n/** β
A rename is a PATCH: same id, same token, same enrolled replicas and Mesh IP. */\nexport const renameNode = (accountId: string, nodeId: string, name: string) =>\n zeroTrust\n .patchTunnelWarpConnector({ accountId, tunnelId: nodeId, name })\n .pipe(Effect.map((raw) => observe(raw) ?? { id: nodeId, name, status: undefined }));\n\n/** Idempotent: a node already gone (404 / code 1002) is a successful delete. */\nexport const deleteNode = (accountId: string, nodeId: string) =>\n zeroTrust.deleteTunnelWarpConnector({ accountId, tunnelId: nodeId }).pipe(\n Effect.asVoid,\n Effect.catchTag('TunnelNotFound', () => Effect.void),\n );\n",
|
|
10
10
|
"/**\n * `Cloudflare.MeshNode` β its props, its attributes, and the pure `diff` that decides between\n * update and replace.\n *\n * β THE ATTRIBUTES NEVER HOLD THE NODE TOKEN. That is the whole reason this resource exists:\n * Alchemy beta.79's `Cloudflare.Tunnel.WarpConnector` calls `getTunnelWarpConnectorToken` in\n * `toAttributes`, so EVERY read, reconcile and list fetches the token, and Alchemy state (which\n * is not encrypted) stores it β `Redacted` hides it from logs, not from the state store. It also\n * has no `ha` prop. The token is a runtime credential: fetch it on demand with\n * `fetchMeshNodeToken` (mesh-node-token.ts) and write it straight to the host.\n * β
A BONUS OF NOT READING IT: the token endpoint needs a Write permission (\"Cloudflare One\n * Connectors Write\", per its API reference, read 2026-09-21), so a plan over this resource can\n * run on a read-only token. WarpConnector's cannot.\n */\nimport type { Diff } from 'alchemy/Diff';\nimport type { Input } from 'alchemy/Input';\nimport { MeshNodeError, type MeshNodeStatus } from './mesh-node-api.ts';\n\nexport interface MeshNodeProps {\n /**\n * The node's name in the account β and its identity when adopting (names are unique per\n * account). β
Changing it RENAMES the node in place (`PATCH`): the id, the token, the\n * enrolled replicas and their Mesh IPs all survive, so a rename never re-enrols a host.\n */\n readonly name: string;\n /**\n * High availability (active-passive replicas sharing one node token).\n *\n * β CREATE-ONLY, AND REQUIRED FOR THAT REASON. Cloudflare's Mesh HA page: \"High availability\n * is set at node creation time and cannot be changed afterward.\" The PATCH body carries no\n * `ha`. So a change REPLACES the node β a new id, a new token, a new Mesh IP per replica β\n * and a default would silently fix an irreversible choice. The API's own default is `false`;\n * the dashboard's is `true`. Say which one you mean.\n * β
A NODE WITH NO ROUTES β A DOOR β IS `ha: false`, AND A SECOND DOOR IS A SECOND NODE. HA\n * fails over the CIDR routes a node advertises; \"Nodes without routes do not benefit from HA\n * failover\", because the node's Mesh IP is tied to the individual replica (same page, \"When to\n * use\" and \"Considerations\", read 2026-09-21). After a failover the promoted replica answers\n * on its own Mesh IP, so nothing that dials a door's first replica follows it. Declare the\n * second door as its own `MeshNode` (own name, token, Mesh IP) and have callers list both.\n * β οΈ `ha` NEEDS MASQUE on the node's device profile (same page).\n */\n readonly ha: boolean;\n}\n\nexport interface MeshNodeAttributes {\n /** The node's UUID (the `warp_connector` tunnel id). Pass it to `fetchMeshNodeToken`. */\n readonly id: string;\n /** The account the provider environment resolved when the node was written. */\n readonly accountId: string;\n readonly name: string;\n /** As of the last read or write; it moves on its own as replicas connect and drop. */\n readonly status: MeshNodeStatus | undefined;\n /**\n * The value the node was CREATED with (or declared with when adopted β see mesh-node.md).\n * β οΈ No documented read returns it: `GET /warp_connector/{id}` has no `ha` field (API\n * reference, read 2026-09-21), so drift here cannot be observed, only prevented.\n */\n readonly ha: boolean;\n}\n\n/** β Fail closed on a declaration the API would accept but nobody meant. */\nexport const validateMeshNode = (props: MeshNodeProps): MeshNodeError | undefined => {\n if (typeof props.name !== 'string' || props.name.trim().length === 0) {\n return new MeshNodeError({ message: 'MeshNode `name` must be a non-empty string.' });\n }\n if (props.name !== props.name.trim()) {\n return new MeshNodeError({\n message: `MeshNode name \"${props.name}\" has leading or trailing whitespace; adoption matches names exactly.`,\n });\n }\n if (typeof props.ha !== 'boolean') {\n return new MeshNodeError({\n message: 'MeshNode `ha` must be true or false. It is create-only, so it has no default.',\n });\n }\n return undefined;\n};\n\n/**\n * update or replace, from the declaration and what was last written.\n *\n * - Account changed β `replace`, create-first: names are unique per ACCOUNT, so the two\n * generations cannot collide.\n * - `ha` changed β `replace`. β `deleteFirst` when the name stays the same: the new node needs\n * the name the old one holds, so create-first would fail with code 1013 (or, worse, a\n * create path that \"converged\" on the old node would record an `ha` it does not have).\n * Create-first when the name changes in the same edit, because then nothing collides.\n * β οΈ UNDER THE DEFAULT `retain` (mesh-node.ts) the engine skips the old node's delete in both\n * orders: delete-first then REFUSES at the create (mesh-node-lifecycle.ts) until the deploy\n * opts in with `RemovalPolicy.destroy()`, and create-first leaves the old node live, unmanaged.\n * The answer here is the same under both policies: `diff` is not handed the policy (Provider.ts,\n * its input), and delete-first is exactly what lets the opted-in deploy reuse the name.\n * - Name changed alone β `update` (a PATCH rename).\n * - Otherwise β no opinion; the engine compares props.\n *\n * β
An unresolved `ha` gives no answer here; `reconcile` re-checks it once resolved and refuses\n * rather than updating across a create-only change. An unresolved `name` next to a changed `ha`\n * counts as \"unchanged\", which picks the delete-first order that cannot collide.\n */\nexport const diffMeshNode = (\n news: Input<MeshNodeProps>,\n output: MeshNodeAttributes | undefined,\n accountId: string,\n): Diff | undefined => {\n if (output === undefined) return undefined;\n if (output.accountId !== accountId) return { action: 'replace' };\n const fields = news as { readonly name?: unknown; readonly ha?: unknown };\n const name = typeof fields.name === 'string' ? fields.name : undefined;\n if (typeof fields.ha === 'boolean' && fields.ha !== output.ha) {\n const sameName = name === undefined || name === output.name;\n return sameName ? { action: 'replace', deleteFirst: true } : { action: 'replace' };\n }\n if (name !== undefined && name !== output.name) return { action: 'update' };\n return undefined;\n};\n",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The vocabulary of an R2 lock rule, and the comparison that decides whether to write one.
|
|
3
3
|
*
|
|
4
|
-
* β
ITS OWN MODULE FOR THE REASON
|
|
4
|
+
* β
ITS OWN MODULE FOR THE REASON `<estate>/litellm/src/equal.ts` IS ONE: the comparison is the
|
|
5
5
|
* riskiest function in the provider and it deserves to be readable and testable without the
|
|
6
6
|
* lifecycle around it. An equality that returned true for everything would make every reconcile
|
|
7
7
|
* a no-op and the resource would silently never write anything β which looks exactly like a
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Create/PATCH bodies for `Forgejo.BranchProtection` β extracted for the 250-line cap.
|
|
3
3
|
*
|
|
4
|
-
* β
READ OFF
|
|
4
|
+
* β
READ OFF `<estate>/mcp-servers/docs/api/upstream/forgejo.json` β create posts
|
|
5
5
|
* `CreateBranchProtectionOption`; update sends `EditBranchProtectionOption` fields only.
|
|
6
6
|
*/
|
|
7
7
|
import type { BranchProtectionProps } from './branch-protection.ts';
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `Forgejo.BranchProtection` β one rule at `/repos/{owner}/{repo}/branch_protections/{name}`.
|
|
3
3
|
*
|
|
4
|
-
* β
READ OFF
|
|
4
|
+
* β
READ OFF `<estate>/mcp-servers/docs/api/upstream/forgejo.json`: create is POST collection;
|
|
5
5
|
* read/update/delete use the branch/rule name in the path segment `{name}`. Response carries
|
|
6
6
|
* `rule_name` (preferred) and deprecated `branch_name`.
|
|
7
7
|
*
|