@homeflare/alchemy 0.1.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/LICENSE +21 -0
- package/README.md +62 -0
- package/dist/cloudflare/client.d.ts +45 -0
- package/dist/cloudflare/client.d.ts.map +1 -0
- package/dist/cloudflare/index.d.ts +11 -0
- package/dist/cloudflare/index.d.ts.map +1 -0
- package/dist/cloudflare/index.js +129 -0
- package/dist/cloudflare/index.js.map +13 -0
- package/dist/cloudflare/lock-rules.d.ts +50 -0
- package/dist/cloudflare/lock-rules.d.ts.map +1 -0
- package/dist/cloudflare/providers.d.ts +19 -0
- package/dist/cloudflare/providers.d.ts.map +1 -0
- package/dist/cloudflare/r2-bucket-lock.d.ts +100 -0
- package/dist/cloudflare/r2-bucket-lock.d.ts.map +1 -0
- package/dist/forgejo/branch-protection-form.d.ts +19 -0
- package/dist/forgejo/branch-protection-form.d.ts.map +1 -0
- package/dist/forgejo/branch-protection.d.ts +47 -0
- package/dist/forgejo/branch-protection.d.ts.map +1 -0
- package/dist/forgejo/client.d.ts +37 -0
- package/dist/forgejo/client.d.ts.map +1 -0
- package/dist/forgejo/index.d.ts +18 -0
- package/dist/forgejo/index.d.ts.map +1 -0
- package/dist/forgejo/index.js +479 -0
- package/dist/forgejo/index.js.map +21 -0
- package/dist/forgejo/org-actions-secrets.d.ts +45 -0
- package/dist/forgejo/org-actions-secrets.d.ts.map +1 -0
- package/dist/forgejo/org-label.d.ts +46 -0
- package/dist/forgejo/org-label.d.ts.map +1 -0
- package/dist/forgejo/org-team.d.ts +42 -0
- package/dist/forgejo/org-team.d.ts.map +1 -0
- package/dist/forgejo/repo-webhook-form.d.ts +27 -0
- package/dist/forgejo/repo-webhook-form.d.ts.map +1 -0
- package/dist/forgejo/repo-webhook.d.ts +46 -0
- package/dist/forgejo/repo-webhook.d.ts.map +1 -0
- package/dist/forgejo/repository.d.ts +45 -0
- package/dist/forgejo/repository.d.ts.map +1 -0
- package/dist/forgejo/resource.d.ts +83 -0
- package/dist/forgejo/resource.d.ts.map +1 -0
- package/dist/forgejo/team-member.d.ts +39 -0
- package/dist/forgejo/team-member.d.ts.map +1 -0
- package/dist/forgejo/values.d.ts +24 -0
- package/dist/forgejo/values.d.ts.map +1 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +9 -0
- package/dist/openbao/auth-role-form.d.ts +38 -0
- package/dist/openbao/auth-role-form.d.ts.map +1 -0
- package/dist/openbao/auth-role.d.ts +20 -0
- package/dist/openbao/auth-role.d.ts.map +1 -0
- package/dist/openbao/bao-address.d.ts +72 -0
- package/dist/openbao/bao-address.d.ts.map +1 -0
- package/dist/openbao/bao-http.d.ts +71 -0
- package/dist/openbao/bao-http.d.ts.map +1 -0
- package/dist/openbao/bao-status.d.ts +74 -0
- package/dist/openbao/bao-status.d.ts.map +1 -0
- package/dist/openbao/cloudflare-group-scope.d.ts +73 -0
- package/dist/openbao/cloudflare-group-scope.d.ts.map +1 -0
- package/dist/openbao/cloudflare-parity-catalog.d.ts +28 -0
- package/dist/openbao/cloudflare-parity-catalog.d.ts.map +1 -0
- package/dist/openbao/cloudflare-parity-snapshot.d.ts +31 -0
- package/dist/openbao/cloudflare-parity-snapshot.d.ts.map +1 -0
- package/dist/openbao/cloudflare-parity.d.ts +14 -0
- package/dist/openbao/cloudflare-parity.d.ts.map +1 -0
- package/dist/openbao/cloudflare-permission-groups-solve.d.ts +85 -0
- package/dist/openbao/cloudflare-permission-groups-solve.d.ts.map +1 -0
- package/dist/openbao/cloudflare-permission-groups.d.ts +87 -0
- package/dist/openbao/cloudflare-permission-groups.d.ts.map +1 -0
- package/dist/openbao/cloudflare-policy.d.ts +65 -0
- package/dist/openbao/cloudflare-policy.d.ts.map +1 -0
- package/dist/openbao/cloudflare-role-form.d.ts +110 -0
- package/dist/openbao/cloudflare-role-form.d.ts.map +1 -0
- package/dist/openbao/cloudflare-role-wire.d.ts +27 -0
- package/dist/openbao/cloudflare-role-wire.d.ts.map +1 -0
- package/dist/openbao/cloudflare-role.d.ts +35 -0
- package/dist/openbao/cloudflare-role.d.ts.map +1 -0
- package/dist/openbao/cloudflare-roles-config.d.ts +67 -0
- package/dist/openbao/cloudflare-roles-config.d.ts.map +1 -0
- package/dist/openbao/cloudflare-roles-expand.d.ts +76 -0
- package/dist/openbao/cloudflare-roles-expand.d.ts.map +1 -0
- package/dist/openbao/digest.d.ts +24 -0
- package/dist/openbao/digest.d.ts.map +1 -0
- package/dist/openbao/fake-bao.d.ts +40 -0
- package/dist/openbao/fake-bao.d.ts.map +1 -0
- package/dist/openbao/forgejo-bootstrap-parse.d.ts +46 -0
- package/dist/openbao/forgejo-bootstrap-parse.d.ts.map +1 -0
- package/dist/openbao/forgejo-bootstrap.d.ts +29 -0
- package/dist/openbao/forgejo-bootstrap.d.ts.map +1 -0
- package/dist/openbao/index.d.ts +18 -0
- package/dist/openbao/index.d.ts.map +1 -0
- package/dist/openbao/index.js +1158 -0
- package/dist/openbao/index.js.map +33 -0
- package/dist/openbao/mount-form.d.ts +60 -0
- package/dist/openbao/mount-form.d.ts.map +1 -0
- package/dist/openbao/mount-wire.d.ts +24 -0
- package/dist/openbao/mount-wire.d.ts.map +1 -0
- package/dist/openbao/mount.d.ts +20 -0
- package/dist/openbao/mount.d.ts.map +1 -0
- package/dist/openbao/pki-role-form.d.ts +118 -0
- package/dist/openbao/pki-role-form.d.ts.map +1 -0
- package/dist/openbao/pki-role.d.ts +50 -0
- package/dist/openbao/pki-role.d.ts.map +1 -0
- package/dist/openbao/policy-assembly.d.ts +13 -0
- package/dist/openbao/policy-assembly.d.ts.map +1 -0
- package/dist/openbao/policy-wire.d.ts +41 -0
- package/dist/openbao/policy-wire.d.ts.map +1 -0
- package/dist/openbao/policy.d.ts +83 -0
- package/dist/openbao/policy.d.ts.map +1 -0
- package/dist/openbao/proxmox-role-form.d.ts +92 -0
- package/dist/openbao/proxmox-role-form.d.ts.map +1 -0
- package/dist/openbao/proxmox-role.d.ts +47 -0
- package/dist/openbao/proxmox-role.d.ts.map +1 -0
- package/dist/openbao/ssh-role-form.d.ts +99 -0
- package/dist/openbao/ssh-role-form.d.ts.map +1 -0
- package/dist/openbao/ssh-role.d.ts +43 -0
- package/dist/openbao/ssh-role.d.ts.map +1 -0
- package/dist/proxmox/acl.d.ts +72 -0
- package/dist/proxmox/acl.d.ts.map +1 -0
- package/dist/proxmox/api-token-form.d.ts +59 -0
- package/dist/proxmox/api-token-form.d.ts.map +1 -0
- package/dist/proxmox/api-token.d.ts +159 -0
- package/dist/proxmox/api-token.d.ts.map +1 -0
- package/dist/proxmox/backup-job.d.ts +112 -0
- package/dist/proxmox/backup-job.d.ts.map +1 -0
- package/dist/proxmox/ceph-daemon-form.d.ts +70 -0
- package/dist/proxmox/ceph-daemon-form.d.ts.map +1 -0
- package/dist/proxmox/ceph-daemon.d.ts +162 -0
- package/dist/proxmox/ceph-daemon.d.ts.map +1 -0
- package/dist/proxmox/ceph-flag.d.ts +131 -0
- package/dist/proxmox/ceph-flag.d.ts.map +1 -0
- package/dist/proxmox/ceph-fs-wire.d.ts +106 -0
- package/dist/proxmox/ceph-fs-wire.d.ts.map +1 -0
- package/dist/proxmox/ceph-fs.d.ts +124 -0
- package/dist/proxmox/ceph-fs.d.ts.map +1 -0
- package/dist/proxmox/ceph-osd-tree.d.ts +154 -0
- package/dist/proxmox/ceph-osd-tree.d.ts.map +1 -0
- package/dist/proxmox/ceph-osd-write.d.ts +36 -0
- package/dist/proxmox/ceph-osd-write.d.ts.map +1 -0
- package/dist/proxmox/ceph-osd.d.ts +102 -0
- package/dist/proxmox/ceph-osd.d.ts.map +1 -0
- package/dist/proxmox/ceph-pool-form.d.ts +217 -0
- package/dist/proxmox/ceph-pool-form.d.ts.map +1 -0
- package/dist/proxmox/ceph-pool-settle.d.ts +39 -0
- package/dist/proxmox/ceph-pool-settle.d.ts.map +1 -0
- package/dist/proxmox/ceph-pool.d.ts +48 -0
- package/dist/proxmox/ceph-pool.d.ts.map +1 -0
- package/dist/proxmox/client.d.ts +64 -0
- package/dist/proxmox/client.d.ts.map +1 -0
- package/dist/proxmox/credentials.d.ts +153 -0
- package/dist/proxmox/credentials.d.ts.map +1 -0
- package/dist/proxmox/firewall-alias-form.d.ts +98 -0
- package/dist/proxmox/firewall-alias-form.d.ts.map +1 -0
- package/dist/proxmox/firewall-alias.d.ts +141 -0
- package/dist/proxmox/firewall-alias.d.ts.map +1 -0
- package/dist/proxmox/generated/pbs.d.ts +388 -0
- package/dist/proxmox/generated/pbs.d.ts.map +1 -0
- package/dist/proxmox/generated/pve.d.ts +4713 -0
- package/dist/proxmox/generated/pve.d.ts.map +1 -0
- package/dist/proxmox/group.d.ts +104 -0
- package/dist/proxmox/group.d.ts.map +1 -0
- package/dist/proxmox/ha-resource.d.ts +104 -0
- package/dist/proxmox/ha-resource.d.ts.map +1 -0
- package/dist/proxmox/ha-rule-form.d.ts +103 -0
- package/dist/proxmox/ha-rule-form.d.ts.map +1 -0
- package/dist/proxmox/ha-rule.d.ts +156 -0
- package/dist/proxmox/ha-rule.d.ts.map +1 -0
- package/dist/proxmox/index.d.ts +43 -0
- package/dist/proxmox/index.d.ts.map +1 -0
- package/dist/proxmox/index.js +2322 -0
- package/dist/proxmox/index.js.map +70 -0
- package/dist/proxmox/lease-cache.d.ts +98 -0
- package/dist/proxmox/lease-cache.d.ts.map +1 -0
- package/dist/proxmox/lxc.d.ts +59 -0
- package/dist/proxmox/lxc.d.ts.map +1 -0
- package/dist/proxmox/members.d.ts +56 -0
- package/dist/proxmox/members.d.ts.map +1 -0
- package/dist/proxmox/metric-server-form.d.ts +53 -0
- package/dist/proxmox/metric-server-form.d.ts.map +1 -0
- package/dist/proxmox/metric-server-otel.d.ts +49 -0
- package/dist/proxmox/metric-server-otel.d.ts.map +1 -0
- package/dist/proxmox/metric-server.d.ts +123 -0
- package/dist/proxmox/metric-server.d.ts.map +1 -0
- package/dist/proxmox/network-apply-read.d.ts +93 -0
- package/dist/proxmox/network-apply-read.d.ts.map +1 -0
- package/dist/proxmox/network-apply.d.ts +102 -0
- package/dist/proxmox/network-apply.d.ts.map +1 -0
- package/dist/proxmox/node-network-form.d.ts +128 -0
- package/dist/proxmox/node-network-form.d.ts.map +1 -0
- package/dist/proxmox/node-network.d.ts +173 -0
- package/dist/proxmox/node-network.d.ts.map +1 -0
- package/dist/proxmox/notification-target-form.d.ts +44 -0
- package/dist/proxmox/notification-target-form.d.ts.map +1 -0
- package/dist/proxmox/notification-target.d.ts +146 -0
- package/dist/proxmox/notification-target.d.ts.map +1 -0
- package/dist/proxmox/pbs-datastore-form.d.ts +147 -0
- package/dist/proxmox/pbs-datastore-form.d.ts.map +1 -0
- package/dist/proxmox/pbs-datastore-guard.d.ts +55 -0
- package/dist/proxmox/pbs-datastore-guard.d.ts.map +1 -0
- package/dist/proxmox/pbs-datastore.d.ts +140 -0
- package/dist/proxmox/pbs-datastore.d.ts.map +1 -0
- package/dist/proxmox/pbs-prune-job-form.d.ts +97 -0
- package/dist/proxmox/pbs-prune-job-form.d.ts.map +1 -0
- package/dist/proxmox/pbs-prune-job.d.ts +168 -0
- package/dist/proxmox/pbs-prune-job.d.ts.map +1 -0
- package/dist/proxmox/pbs-sync-job-form.d.ts +83 -0
- package/dist/proxmox/pbs-sync-job-form.d.ts.map +1 -0
- package/dist/proxmox/pbs-sync-job.d.ts +246 -0
- package/dist/proxmox/pbs-sync-job.d.ts.map +1 -0
- package/dist/proxmox/pbs-verify-job-form.d.ts +114 -0
- package/dist/proxmox/pbs-verify-job-form.d.ts.map +1 -0
- package/dist/proxmox/pbs-verify-job.d.ts +145 -0
- package/dist/proxmox/pbs-verify-job.d.ts.map +1 -0
- package/dist/proxmox/pool.d.ts +42 -0
- package/dist/proxmox/pool.d.ts.map +1 -0
- package/dist/proxmox/qemu.d.ts +46 -0
- package/dist/proxmox/qemu.d.ts.map +1 -0
- package/dist/proxmox/replication-job-form.d.ts +71 -0
- package/dist/proxmox/replication-job-form.d.ts.map +1 -0
- package/dist/proxmox/replication-job.d.ts +162 -0
- package/dist/proxmox/replication-job.d.ts.map +1 -0
- package/dist/proxmox/resource.d.ts +157 -0
- package/dist/proxmox/resource.d.ts.map +1 -0
- package/dist/proxmox/role.d.ts +97 -0
- package/dist/proxmox/role.d.ts.map +1 -0
- package/dist/proxmox/sdn-apply-read.d.ts +80 -0
- package/dist/proxmox/sdn-apply-read.d.ts.map +1 -0
- package/dist/proxmox/sdn-apply.d.ts +63 -0
- package/dist/proxmox/sdn-apply.d.ts.map +1 -0
- package/dist/proxmox/sdn-subnet-form.d.ts +111 -0
- package/dist/proxmox/sdn-subnet-form.d.ts.map +1 -0
- package/dist/proxmox/sdn-subnet.d.ts +161 -0
- package/dist/proxmox/sdn-subnet.d.ts.map +1 -0
- package/dist/proxmox/sdn-vnet.d.ts +113 -0
- package/dist/proxmox/sdn-vnet.d.ts.map +1 -0
- package/dist/proxmox/sdn-zone.d.ts +126 -0
- package/dist/proxmox/sdn-zone.d.ts.map +1 -0
- package/dist/proxmox/storage.d.ts +120 -0
- package/dist/proxmox/storage.d.ts.map +1 -0
- package/dist/proxmox/user.d.ts +107 -0
- package/dist/proxmox/user.d.ts.map +1 -0
- package/dist/proxmox/values.d.ts +134 -0
- package/dist/proxmox/values.d.ts.map +1 -0
- package/dist/proxmox/zfs-pool-write.d.ts +76 -0
- package/dist/proxmox/zfs-pool-write.d.ts.map +1 -0
- package/dist/proxmox/zfs-pool.d.ts +146 -0
- package/dist/proxmox/zfs-pool.d.ts.map +1 -0
- package/dist/talos/credentials.d.ts +30 -0
- package/dist/talos/credentials.d.ts.map +1 -0
- package/dist/talos/index.d.ts +15 -0
- package/dist/talos/index.d.ts.map +1 -0
- package/dist/talos/index.js +392 -0
- package/dist/talos/index.js.map +16 -0
- package/dist/talos/kubeconfig.d.ts +53 -0
- package/dist/talos/kubeconfig.d.ts.map +1 -0
- package/dist/talos/resource.d.ts +32 -0
- package/dist/talos/resource.d.ts.map +1 -0
- package/dist/talos/talos-bootstrap.d.ts +27 -0
- package/dist/talos/talos-bootstrap.d.ts.map +1 -0
- package/dist/talos/talos-cluster-health.d.ts +36 -0
- package/dist/talos/talos-cluster-health.d.ts.map +1 -0
- package/dist/talos/talos-machine-config.d.ts +41 -0
- package/dist/talos/talos-machine-config.d.ts.map +1 -0
- package/dist/talos/talosctl.d.ts +29 -0
- package/dist/talos/talosctl.d.ts.map +1 -0
- package/dist/talos/values.d.ts +20 -0
- package/dist/talos/values.d.ts.map +1 -0
- package/package.json +57 -0
- package/src/cloudflare/client.ts +76 -0
- package/src/cloudflare/index.ts +10 -0
- package/src/cloudflare/lock-rules.ts +60 -0
- package/src/cloudflare/providers.ts +23 -0
- package/src/cloudflare/r2-bucket-lock.ts +220 -0
- package/src/forgejo/branch-protection-form.ts +29 -0
- package/src/forgejo/branch-protection.ts +101 -0
- package/src/forgejo/client.ts +136 -0
- package/src/forgejo/index.ts +17 -0
- package/src/forgejo/org-actions-secrets.ts +99 -0
- package/src/forgejo/org-label.ts +100 -0
- package/src/forgejo/org-team.ts +105 -0
- package/src/forgejo/repo-webhook-form.ts +47 -0
- package/src/forgejo/repo-webhook.ts +118 -0
- package/src/forgejo/repository.ts +126 -0
- package/src/forgejo/resource.ts +171 -0
- package/src/forgejo/team-member.ts +97 -0
- package/src/forgejo/values.test.ts +48 -0
- package/src/forgejo/values.ts +61 -0
- package/src/index.ts +18 -0
- package/src/openbao/auth-role-form.ts +112 -0
- package/src/openbao/auth-role.ts +101 -0
- package/src/openbao/bao-address.test.ts +89 -0
- package/src/openbao/bao-address.ts +97 -0
- package/src/openbao/bao-http-gate.test.ts +88 -0
- package/src/openbao/bao-http-unix.test.ts +70 -0
- package/src/openbao/bao-http.test.ts +161 -0
- package/src/openbao/bao-http.ts +185 -0
- package/src/openbao/bao-status.test.ts +114 -0
- package/src/openbao/bao-status.ts +137 -0
- package/src/openbao/cloudflare-group-scope.test.ts +103 -0
- package/src/openbao/cloudflare-group-scope.ts +83 -0
- package/src/openbao/cloudflare-parity-catalog.ts +104 -0
- package/src/openbao/cloudflare-parity-snapshot.ts +84 -0
- package/src/openbao/cloudflare-parity.ts +152 -0
- package/src/openbao/cloudflare-permission-groups-solve.test.ts +171 -0
- package/src/openbao/cloudflare-permission-groups-solve.ts +197 -0
- package/src/openbao/cloudflare-permission-groups.test.ts +369 -0
- package/src/openbao/cloudflare-permission-groups.ts +299 -0
- package/src/openbao/cloudflare-policy.ts +180 -0
- package/src/openbao/cloudflare-role-form.test.ts +177 -0
- package/src/openbao/cloudflare-role-form.ts +183 -0
- package/src/openbao/cloudflare-role-wire.test.ts +132 -0
- package/src/openbao/cloudflare-role-wire.ts +41 -0
- package/src/openbao/cloudflare-role.ts +189 -0
- package/src/openbao/cloudflare-roles-config.ts +230 -0
- package/src/openbao/cloudflare-roles-expand-bucket.test.ts +134 -0
- package/src/openbao/cloudflare-roles-expand.ts +231 -0
- package/src/openbao/digest.ts +26 -0
- package/src/openbao/fake-bao.ts +84 -0
- package/src/openbao/forgejo-bootstrap-parse.test.ts +76 -0
- package/src/openbao/forgejo-bootstrap-parse.ts +90 -0
- package/src/openbao/forgejo-bootstrap.ts +181 -0
- package/src/openbao/index.ts +17 -0
- package/src/openbao/mount-form.ts +147 -0
- package/src/openbao/mount-wire.test.ts +108 -0
- package/src/openbao/mount-wire.ts +79 -0
- package/src/openbao/mount.ts +132 -0
- package/src/openbao/pki-role-form.ts +248 -0
- package/src/openbao/pki-role.ts +202 -0
- package/src/openbao/policy-assembly.test.ts +28 -0
- package/src/openbao/policy-assembly.ts +13 -0
- package/src/openbao/policy-wire.test.ts +90 -0
- package/src/openbao/policy-wire.ts +53 -0
- package/src/openbao/policy.ts +212 -0
- package/src/openbao/proxmox-role-form.ts +174 -0
- package/src/openbao/proxmox-role.ts +214 -0
- package/src/openbao/ssh-role-form.ts +248 -0
- package/src/openbao/ssh-role.ts +240 -0
- package/src/proxmox/acl.ts +267 -0
- package/src/proxmox/api-token-form.ts +122 -0
- package/src/proxmox/api-token.ts +222 -0
- package/src/proxmox/backup-job.ts +213 -0
- package/src/proxmox/ceph-daemon-form.ts +84 -0
- package/src/proxmox/ceph-daemon.ts +243 -0
- package/src/proxmox/ceph-flag.ts +249 -0
- package/src/proxmox/ceph-fs-wire.ts +246 -0
- package/src/proxmox/ceph-fs.ts +249 -0
- package/src/proxmox/ceph-osd-tree.ts +193 -0
- package/src/proxmox/ceph-osd-write.ts +76 -0
- package/src/proxmox/ceph-osd.ts +244 -0
- package/src/proxmox/ceph-pool-form.ts +249 -0
- package/src/proxmox/ceph-pool-settle.ts +70 -0
- package/src/proxmox/ceph-pool.ts +212 -0
- package/src/proxmox/client.test.ts +185 -0
- package/src/proxmox/client.ts +240 -0
- package/src/proxmox/credentials.test.ts +144 -0
- package/src/proxmox/credentials.ts +254 -0
- package/src/proxmox/firewall-alias-form.ts +112 -0
- package/src/proxmox/firewall-alias.ts +208 -0
- package/src/proxmox/generated/pbs.ts +461 -0
- package/src/proxmox/generated/pve.ts +7344 -0
- package/src/proxmox/group.ts +199 -0
- package/src/proxmox/ha-resource.ts +169 -0
- package/src/proxmox/ha-rule-form.ts +185 -0
- package/src/proxmox/ha-rule.ts +240 -0
- package/src/proxmox/index.ts +42 -0
- package/src/proxmox/lease-cache.test.ts +163 -0
- package/src/proxmox/lease-cache.ts +131 -0
- package/src/proxmox/lxc.ts +117 -0
- package/src/proxmox/members.test.ts +54 -0
- package/src/proxmox/members.ts +174 -0
- package/src/proxmox/metric-server-form.ts +128 -0
- package/src/proxmox/metric-server-otel.ts +99 -0
- package/src/proxmox/metric-server.ts +214 -0
- package/src/proxmox/network-apply-read.ts +228 -0
- package/src/proxmox/network-apply.ts +244 -0
- package/src/proxmox/node-network-form.ts +213 -0
- package/src/proxmox/node-network.ts +243 -0
- package/src/proxmox/notification-target-form.ts +104 -0
- package/src/proxmox/notification-target.ts +222 -0
- package/src/proxmox/pbs-datastore-form.ts +248 -0
- package/src/proxmox/pbs-datastore-guard.ts +105 -0
- package/src/proxmox/pbs-datastore.ts +271 -0
- package/src/proxmox/pbs-prune-job-form.ts +208 -0
- package/src/proxmox/pbs-prune-job.ts +227 -0
- package/src/proxmox/pbs-sync-job-form.ts +234 -0
- package/src/proxmox/pbs-sync-job.ts +258 -0
- package/src/proxmox/pbs-verify-job-form.ts +131 -0
- package/src/proxmox/pbs-verify-job.ts +227 -0
- package/src/proxmox/pool.test.ts +42 -0
- package/src/proxmox/pool.ts +80 -0
- package/src/proxmox/qemu.ts +89 -0
- package/src/proxmox/replication-job-form.ts +89 -0
- package/src/proxmox/replication-job.ts +248 -0
- package/src/proxmox/resource.ts +257 -0
- package/src/proxmox/role.ts +161 -0
- package/src/proxmox/sdn-apply-read.test.ts +152 -0
- package/src/proxmox/sdn-apply-read.ts +189 -0
- package/src/proxmox/sdn-apply.ts +154 -0
- package/src/proxmox/sdn-subnet-form.ts +156 -0
- package/src/proxmox/sdn-subnet.ts +238 -0
- package/src/proxmox/sdn-vnet.ts +228 -0
- package/src/proxmox/sdn-zone.ts +246 -0
- package/src/proxmox/storage.ts +245 -0
- package/src/proxmox/user.ts +214 -0
- package/src/proxmox/values.test.ts +193 -0
- package/src/proxmox/values.ts +203 -0
- package/src/proxmox/zfs-pool-write.ts +130 -0
- package/src/proxmox/zfs-pool.ts +253 -0
- package/src/talos/credentials.ts +136 -0
- package/src/talos/index.ts +14 -0
- package/src/talos/kubeconfig.ts +170 -0
- package/src/talos/resource.ts +31 -0
- package/src/talos/talos-bootstrap.ts +109 -0
- package/src/talos/talos-cluster-health.ts +119 -0
- package/src/talos/talos-machine-config.ts +146 -0
- package/src/talos/talosctl.ts +94 -0
- package/src/talos/values.test.ts +80 -0
- package/src/talos/values.ts +70 -0
|
@@ -0,0 +1,369 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Both permission-group sources against fake-bao: (a) the engine endpoint — resolved and cached per
|
|
3
|
+
* mount, the seven live two-id names separated by scope, the bucket-scoped R2 pair, unknown, a
|
|
4
|
+
* two-id name asked at a scope it is not listed at, DENIED, undeployed and still-ambiguous;
|
|
5
|
+
* (b) ⚠️ the live-roles bootstrap — resolved across an account's mounts, unknown, and ambiguous
|
|
6
|
+
* when a live role contradicts the rest.
|
|
7
|
+
*
|
|
8
|
+
* ★ (a)'s FIXTURE IS THE LIVE LIST'S SHAPE, ids and all, transcribed from `bao read
|
|
9
|
+
* cloudflare-<account>-platform/permission-groups` on 2026-09-15 — the seven pairs are the
|
|
10
|
+
* reason this file's key is (name, scope) and not name.
|
|
11
|
+
*
|
|
12
|
+
* ⚠️ (b)'s CASES STAY, AND ARE NOT VACUOUS, THOUGH THE STACK STOPPED WIRING IT ON 2026-09-15.
|
|
13
|
+
* `permissionGroupsBootstrapLayer` is kept as the only source that works while the
|
|
14
|
+
* `permission-groups` grant is unapplied (cloudflare-roles.ts), so these four are the proof it
|
|
15
|
+
* still works when it is reached for. They retire with the function, not before.
|
|
16
|
+
*/
|
|
17
|
+
import assert from 'node:assert/strict';
|
|
18
|
+
import { describe, it } from 'node:test';
|
|
19
|
+
import * as Effect from 'effect/Effect';
|
|
20
|
+
import { GROUP_SCOPES, groupKey } from './cloudflare-group-scope.ts';
|
|
21
|
+
import {
|
|
22
|
+
CloudflareGroupsError,
|
|
23
|
+
type GroupRef,
|
|
24
|
+
permissionGroupsFromEngine,
|
|
25
|
+
permissionGroupsFromLiveRoles,
|
|
26
|
+
} from './cloudflare-permission-groups.ts';
|
|
27
|
+
import type { ExpandedRole } from './cloudflare-roles-expand.ts';
|
|
28
|
+
import { type Reply, type Seen, run, runFailure, withFake } from './fake-bao.ts';
|
|
29
|
+
|
|
30
|
+
const MOUNT = 'cloudflare-acme-dns';
|
|
31
|
+
|
|
32
|
+
const engine =
|
|
33
|
+
(groups: unknown[]) =>
|
|
34
|
+
(seen: Seen): Reply =>
|
|
35
|
+
seen.path === `/v1/${MOUNT}/permission-groups`
|
|
36
|
+
? { json: { data: { permission_groups: groups } }, status: 200 }
|
|
37
|
+
: { json: { errors: [] }, status: 404 };
|
|
38
|
+
|
|
39
|
+
const resolveWith = <E>(
|
|
40
|
+
source: Effect.Effect<{ resolve: (m: string, r: readonly GroupRef[]) => E }>,
|
|
41
|
+
) => source;
|
|
42
|
+
|
|
43
|
+
const at = (scope: string) => (name: string) => ({ name, scope });
|
|
44
|
+
const zone = at(GROUP_SCOPES.zone);
|
|
45
|
+
const account = at(GROUP_SCOPES.account);
|
|
46
|
+
const bucket = at(GROUP_SCOPES.bucket);
|
|
47
|
+
|
|
48
|
+
describe('permissionGroupsFromEngine', () => {
|
|
49
|
+
const GROUPS = [
|
|
50
|
+
{ id: 'id-read', name: 'DNS Read', scopes: ['com.cloudflare.api.account.zone'] },
|
|
51
|
+
{ id: 'id-write', name: 'DNS Write', scopes: ['com.cloudflare.api.account.zone'] },
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
it('resolves names from GET <mount>/permission-groups, asking once per mount', async () => {
|
|
55
|
+
await withFake(engine(GROUPS), async (bao) => {
|
|
56
|
+
const resolved = await run(
|
|
57
|
+
{ BAO_ADDR: bao.address },
|
|
58
|
+
Effect.flatMap(resolveWith(permissionGroupsFromEngine()), (groups) =>
|
|
59
|
+
Effect.zip(
|
|
60
|
+
groups.resolve(MOUNT, [zone('DNS Read')]),
|
|
61
|
+
groups.resolve(`${MOUNT}/`, [zone('DNS Write')]),
|
|
62
|
+
),
|
|
63
|
+
),
|
|
64
|
+
);
|
|
65
|
+
assert.deepEqual(
|
|
66
|
+
resolved.map((map) => Object.fromEntries(map)),
|
|
67
|
+
[
|
|
68
|
+
{ [groupKey('DNS Read', GROUP_SCOPES.zone)]: 'id-read' },
|
|
69
|
+
{ [groupKey('DNS Write', GROUP_SCOPES.zone)]: 'id-write' },
|
|
70
|
+
],
|
|
71
|
+
);
|
|
72
|
+
assert.equal(bao.seen.length, 1);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* ⛔ THE SEVEN LIVE PAIRS, TRANSCRIBED FROM `bao read cloudflare-<account>-platform/
|
|
78
|
+
* permission-groups` ON 2026-09-15: each name listed twice, one account-scoped id and one
|
|
79
|
+
* zone-scoped id. Keyed by name this is the failure that stopped the first engine plan; keyed
|
|
80
|
+
* by (name, scope) each entry asks for the one its resource is gated at.
|
|
81
|
+
*/
|
|
82
|
+
const AMBIGUOUS = [
|
|
83
|
+
[
|
|
84
|
+
'Access: Apps and Policies Read',
|
|
85
|
+
'7ea222f6d5064cfa89ea366d7c1fee89',
|
|
86
|
+
'eb258a38ea634c86a0c89da6b27cb6b6',
|
|
87
|
+
],
|
|
88
|
+
[
|
|
89
|
+
'Access: Apps and Policies Revoke',
|
|
90
|
+
'6c9d1cfcfc6840a987d1b5bfb880a841',
|
|
91
|
+
'6db4e222e21248ac96a3f4c2a81e3b41',
|
|
92
|
+
],
|
|
93
|
+
[
|
|
94
|
+
'Access: Apps and Policies Write',
|
|
95
|
+
'1e13c5124ca64b72b1969a67e8829049',
|
|
96
|
+
'959972745952452f8be2452be8cbb9f2',
|
|
97
|
+
],
|
|
98
|
+
['Disable ESC Read', '29eefa0805f94fdfae2b058b5b52f319', 'e199d584e69344eba202452019deafe3'],
|
|
99
|
+
['Disable ESC Write', '18555e39c5ba40d284dde87eda845a90', '9110d9dd749e464fb9f3961a2064efc5'],
|
|
100
|
+
['Logs Read', '6a315a56f18441e59ed03352369ae956', 'c4a30cd58c5d42619c86a3c36c441e2d'],
|
|
101
|
+
['Logs Write', '96163bd1b0784f62b3e44ed8c2ab1eb6', '3e0b5820118e47f3922f7c989e673882'],
|
|
102
|
+
] as const;
|
|
103
|
+
|
|
104
|
+
const LIVE_SHAPED = [
|
|
105
|
+
...AMBIGUOUS.flatMap(([name, accountId, zoneId]) => [
|
|
106
|
+
{ id: accountId, name, scopes: [GROUP_SCOPES.account] },
|
|
107
|
+
{ id: zoneId, name, scopes: [GROUP_SCOPES.zone] },
|
|
108
|
+
]),
|
|
109
|
+
// The two bucket-scoped groups the pgBackRest role needs, and their account-scoped namesakes.
|
|
110
|
+
{
|
|
111
|
+
id: 'r2-item-read',
|
|
112
|
+
name: 'Workers R2 Storage Bucket Item Read',
|
|
113
|
+
scopes: [GROUP_SCOPES.bucket],
|
|
114
|
+
},
|
|
115
|
+
{
|
|
116
|
+
id: 'r2-item-write',
|
|
117
|
+
name: 'Workers R2 Storage Bucket Item Write',
|
|
118
|
+
scopes: [GROUP_SCOPES.bucket],
|
|
119
|
+
},
|
|
120
|
+
{ id: 'r2-read', name: 'Workers R2 Storage Read', scopes: [GROUP_SCOPES.account] },
|
|
121
|
+
...GROUPS,
|
|
122
|
+
];
|
|
123
|
+
|
|
124
|
+
it('separates the seven two-id names by the scope the asking entry is gated at', async () => {
|
|
125
|
+
await withFake(engine(LIVE_SHAPED), async (bao) => {
|
|
126
|
+
const resolved = await run(
|
|
127
|
+
{ BAO_ADDR: bao.address },
|
|
128
|
+
Effect.flatMap(resolveWith(permissionGroupsFromEngine()), (groups) =>
|
|
129
|
+
groups.resolve(MOUNT, [
|
|
130
|
+
...AMBIGUOUS.map(([name]) => account(name)),
|
|
131
|
+
...AMBIGUOUS.map(([name]) => zone(name)),
|
|
132
|
+
]),
|
|
133
|
+
),
|
|
134
|
+
);
|
|
135
|
+
assert.deepEqual(
|
|
136
|
+
AMBIGUOUS.map(([name]) => [
|
|
137
|
+
resolved.get(groupKey(name, GROUP_SCOPES.account)),
|
|
138
|
+
resolved.get(groupKey(name, GROUP_SCOPES.zone)),
|
|
139
|
+
]),
|
|
140
|
+
AMBIGUOUS.map(([, accountId, zoneId]) => [accountId, zoneId]),
|
|
141
|
+
);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* ⛔ r2-backup-mini's ENTRY IS BUCKET-SCOPED (`bucket: homeflare-pg-mini`), and the two groups it
|
|
147
|
+
* names are the only two the account lists at `com.cloudflare.edge.r2.bucket` — MEASURED
|
|
148
|
+
* 2026-09-15: 2 of 399. A unique name still resolves whatever scope asks, which is why the
|
|
149
|
+
* account-scoped `Workers R2 Storage Read` here answers a bucket-scoped question too.
|
|
150
|
+
*/
|
|
151
|
+
it('resolves the bucket-scoped R2 item groups, and a unique name at any scope', async () => {
|
|
152
|
+
await withFake(engine(LIVE_SHAPED), async (bao) => {
|
|
153
|
+
const resolved = await run(
|
|
154
|
+
{ BAO_ADDR: bao.address },
|
|
155
|
+
Effect.flatMap(resolveWith(permissionGroupsFromEngine()), (groups) =>
|
|
156
|
+
groups.resolve(MOUNT, [
|
|
157
|
+
bucket('Workers R2 Storage Bucket Item Write'),
|
|
158
|
+
bucket('Workers R2 Storage Bucket Item Read'),
|
|
159
|
+
bucket('Workers R2 Storage Read'),
|
|
160
|
+
]),
|
|
161
|
+
),
|
|
162
|
+
);
|
|
163
|
+
assert.deepEqual(Object.fromEntries(resolved), {
|
|
164
|
+
[groupKey('Workers R2 Storage Bucket Item Read', GROUP_SCOPES.bucket)]: 'r2-item-read',
|
|
165
|
+
[groupKey('Workers R2 Storage Bucket Item Write', GROUP_SCOPES.bucket)]: 'r2-item-write',
|
|
166
|
+
[groupKey('Workers R2 Storage Read', GROUP_SCOPES.bucket)]: 'r2-read',
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
/** ⛔ A TWO-ID NAME ASKED AT A SCOPE IT IS NOT LISTED AT IS UNKNOWN, never the other id. */
|
|
172
|
+
it('fails a two-id name at a scope neither of its ids carries', async () => {
|
|
173
|
+
await withFake(engine(LIVE_SHAPED), async (bao) => {
|
|
174
|
+
const error = await runFailure(
|
|
175
|
+
{ BAO_ADDR: bao.address },
|
|
176
|
+
Effect.flatMap(permissionGroupsFromEngine(), (groups) =>
|
|
177
|
+
groups.resolve(MOUNT, [bucket('Logs Write')]),
|
|
178
|
+
),
|
|
179
|
+
);
|
|
180
|
+
assert.match(
|
|
181
|
+
String(error),
|
|
182
|
+
/unknown group "Logs Write" at scope com\.cloudflare\.edge\.r2\.bucket/,
|
|
183
|
+
);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('fails an unknown name rather than dropping it', async () => {
|
|
188
|
+
await withFake(engine(GROUPS), async (bao) => {
|
|
189
|
+
const error = await runFailure(
|
|
190
|
+
{ BAO_ADDR: bao.address },
|
|
191
|
+
Effect.flatMap(permissionGroupsFromEngine(), (groups) =>
|
|
192
|
+
groups.resolve(MOUNT, [zone('DNS Reed')]),
|
|
193
|
+
),
|
|
194
|
+
);
|
|
195
|
+
assert.ok(error instanceof CloudflareGroupsError);
|
|
196
|
+
assert.match(error.message, /unknown group "DNS Reed"/);
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* ⛔ THE FAILURE EVERY PLAN HITS UNTIL THE OPERATOR DEPLOYS THE GRANT. A bare `403: permission
|
|
202
|
+
* denied` names the path and nothing to do about it; this asserts the message carries the
|
|
203
|
+
* stanza and the file, because that is the whole difference between a five-minute fix and a
|
|
204
|
+
* re-investigation of a deployed, working endpoint.
|
|
205
|
+
*/
|
|
206
|
+
it('names the missing policy grant on 403, not a bare permission denied', async () => {
|
|
207
|
+
await withFake(
|
|
208
|
+
() => ({ json: { errors: ['permission denied'] }, status: 403 }),
|
|
209
|
+
async (bao) => {
|
|
210
|
+
const error = await runFailure(
|
|
211
|
+
{ BAO_ADDR: bao.address },
|
|
212
|
+
Effect.flatMap(permissionGroupsFromEngine(), (groups) =>
|
|
213
|
+
groups.resolve(MOUNT, [zone('DNS Read')]),
|
|
214
|
+
),
|
|
215
|
+
);
|
|
216
|
+
assert.ok(error instanceof CloudflareGroupsError);
|
|
217
|
+
assert.match(error.message, /answered 403/);
|
|
218
|
+
assert.match(
|
|
219
|
+
error.message,
|
|
220
|
+
/path "cloudflare-acme-dns\/permission-groups" \{ capabilities = \["read"\] \}/,
|
|
221
|
+
);
|
|
222
|
+
assert.match(error.message, /20-cloudflare-permission-groups\.hcl/);
|
|
223
|
+
},
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it('fails when the endpoint is not deployed (404), never reading it as no groups', async () => {
|
|
228
|
+
await withFake(
|
|
229
|
+
() => ({ json: { errors: [] }, status: 404 }),
|
|
230
|
+
async (bao) => {
|
|
231
|
+
const error = await runFailure(
|
|
232
|
+
{ BAO_ADDR: bao.address },
|
|
233
|
+
Effect.flatMap(permissionGroupsFromEngine(), (groups) =>
|
|
234
|
+
groups.resolve(MOUNT, [zone('DNS Read')]),
|
|
235
|
+
),
|
|
236
|
+
);
|
|
237
|
+
assert.match(String(error), /not deployed/);
|
|
238
|
+
},
|
|
239
|
+
);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* ⛔ TWO IDS SHARING ONE SCOPE IS STILL AMBIGUOUS, and fails the whole mount rather than the one
|
|
244
|
+
* role — the scope is the only thing that can tell a name's ids apart, and here it does not.
|
|
245
|
+
*/
|
|
246
|
+
it('fails a name listed with two ids at the same scope', async () => {
|
|
247
|
+
await withFake(
|
|
248
|
+
engine([...GROUPS, { id: 'id-other', name: 'DNS Read', scopes: [GROUP_SCOPES.zone] }]),
|
|
249
|
+
async (bao) => {
|
|
250
|
+
const error = await runFailure(
|
|
251
|
+
{ BAO_ADDR: bao.address },
|
|
252
|
+
Effect.flatMap(permissionGroupsFromEngine(), (groups) =>
|
|
253
|
+
groups.resolve(MOUNT, [zone('DNS Write')]),
|
|
254
|
+
),
|
|
255
|
+
);
|
|
256
|
+
assert.match(
|
|
257
|
+
String(error),
|
|
258
|
+
/ambiguous: "DNS Read" is listed with two ids at scope com\.cloudflare\.api\.account\.zone/,
|
|
259
|
+
);
|
|
260
|
+
},
|
|
261
|
+
);
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
const ZONE = 'com.cloudflare.api.account.zone.z1';
|
|
266
|
+
|
|
267
|
+
const declared = (mount: string, name: string, groups: string[]): ExpandedRole => ({
|
|
268
|
+
account: 'acme',
|
|
269
|
+
accountId: 'acct',
|
|
270
|
+
catalog: { description: '', maxTtl: '1h', permissions: groups, ttl: '5m', zone: 'example.com' },
|
|
271
|
+
description: '',
|
|
272
|
+
maxTtl: '1h',
|
|
273
|
+
mount,
|
|
274
|
+
name,
|
|
275
|
+
policies: [{ effect: 'allow', groupOrder: 'declared', groups, resource: ZONE }],
|
|
276
|
+
surface: mount.split('-').at(-1) ?? '',
|
|
277
|
+
ttl: '5m',
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
/** Live roles, keyed by API path; everything else is a 404. */
|
|
281
|
+
const liveRoles =
|
|
282
|
+
(roles: Record<string, string[]>) =>
|
|
283
|
+
(seen: Seen): Reply => {
|
|
284
|
+
const ids = roles[seen.path];
|
|
285
|
+
if (ids === undefined) return { json: { errors: [] }, status: 404 };
|
|
286
|
+
const policies = JSON.stringify([
|
|
287
|
+
{ effect: 'allow', resources: { [ZONE]: '*' }, permission_groups: ids.map((id) => ({ id })) },
|
|
288
|
+
]);
|
|
289
|
+
return {
|
|
290
|
+
json: { data: { description: '', max_ttl: 3600, name: 'x', policies, ttl: 300 } },
|
|
291
|
+
status: 200,
|
|
292
|
+
};
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* One account, two mounts. `Email Routing` is isolated only by combining them — the shape that
|
|
297
|
+
* made source (b) per account rather than per mount.
|
|
298
|
+
*/
|
|
299
|
+
const DECLARED = new Map([
|
|
300
|
+
[MOUNT, [declared(MOUNT, 'dns-read', ['DNS Read'])]],
|
|
301
|
+
[
|
|
302
|
+
'cloudflare-acme-platform',
|
|
303
|
+
[declared('cloudflare-acme-platform', 'create', ['DNS Read', 'Email Routing'])],
|
|
304
|
+
],
|
|
305
|
+
]);
|
|
306
|
+
|
|
307
|
+
describe('permissionGroupsFromLiveRoles', () => {
|
|
308
|
+
const LIVE = {
|
|
309
|
+
'/v1/cloudflare-acme-dns/roles/dns-read': ['id-read'],
|
|
310
|
+
'/v1/cloudflare-acme-platform/roles/create': ['id-email', 'id-read'],
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
it('resolves a name only a sibling mount of the same account can isolate', async () => {
|
|
314
|
+
await withFake(liveRoles(LIVE), async (bao) => {
|
|
315
|
+
const groups = await run(
|
|
316
|
+
{ BAO_ADDR: bao.address },
|
|
317
|
+
Effect.flatMap(permissionGroupsFromLiveRoles(DECLARED), (source) =>
|
|
318
|
+
source.resolve('cloudflare-acme-platform', [zone('Email Routing'), zone('DNS Read')]),
|
|
319
|
+
),
|
|
320
|
+
);
|
|
321
|
+
assert.deepEqual(Object.fromEntries(groups), {
|
|
322
|
+
[groupKey('DNS Read', GROUP_SCOPES.zone)]: 'id-read',
|
|
323
|
+
[groupKey('Email Routing', GROUP_SCOPES.zone)]: 'id-email',
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
it('fails a name no live role carries — a new group needs the engine endpoint', async () => {
|
|
329
|
+
await withFake(liveRoles(LIVE), async (bao) => {
|
|
330
|
+
const error = await runFailure(
|
|
331
|
+
{ BAO_ADDR: bao.address },
|
|
332
|
+
Effect.flatMap(permissionGroupsFromLiveRoles(DECLARED), (source) =>
|
|
333
|
+
source.resolve(MOUNT, [zone('Zone WAF Write')]),
|
|
334
|
+
),
|
|
335
|
+
);
|
|
336
|
+
assert.ok(error instanceof CloudflareGroupsError);
|
|
337
|
+
assert.match(error.message, /unknown group "Zone WAF Write".*engine endpoint/);
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it('fails the whole account when a live role contradicts the rest', async () => {
|
|
342
|
+
const contradicted = { ...LIVE, '/v1/cloudflare-acme-dns/roles/dns-read': ['id-write'] };
|
|
343
|
+
await withFake(liveRoles(contradicted), async (bao) => {
|
|
344
|
+
const error = await runFailure(
|
|
345
|
+
{ BAO_ADDR: bao.address },
|
|
346
|
+
Effect.flatMap(permissionGroupsFromLiveRoles(DECLARED), (source) =>
|
|
347
|
+
source.resolve(MOUNT, [zone('DNS Read')]),
|
|
348
|
+
),
|
|
349
|
+
);
|
|
350
|
+
// dns-read now teaches `DNS Read` → id-write, and `create` carries id-read instead.
|
|
351
|
+
assert.match(
|
|
352
|
+
String(error),
|
|
353
|
+
/account acme: .*contradicted: cloudflare-acme-platform\/create\[0\] declares "DNS Read" but lacks its id id-write/,
|
|
354
|
+
);
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('fails a mount nothing is declared on', async () => {
|
|
359
|
+
await withFake(liveRoles(LIVE), async (bao) => {
|
|
360
|
+
const error = await runFailure(
|
|
361
|
+
{ BAO_ADDR: bao.address },
|
|
362
|
+
Effect.flatMap(permissionGroupsFromLiveRoles(DECLARED), (source) =>
|
|
363
|
+
source.resolve('cloudflare-acme-security', [zone('DNS Read')]),
|
|
364
|
+
),
|
|
365
|
+
);
|
|
366
|
+
assert.match(String(error), /no declared role on this mount/);
|
|
367
|
+
});
|
|
368
|
+
});
|
|
369
|
+
});
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Permission-group (name, SCOPE) → ID, behind ONE interface with two sources.
|
|
3
|
+
*
|
|
4
|
+
* ⛔ THE KEY IS THE PAIR, AND THAT IS NOT PEDANTRY. MEASURED 2026-09-15 the hour the grant went
|
|
5
|
+
* live: of the account's 401 groups, SEVEN names are listed twice — `Access: Apps and Policies
|
|
6
|
+
* Read`/`Revoke`/`Write`, `Disable ESC Read`/`Write`, `Logs Read`/`Write` — each pair one id
|
|
7
|
+
* scoped `com.cloudflare.api.account` and one scoped `com.cloudflare.api.account.zone`. Keyed by
|
|
8
|
+
* name alone the whole plan fails as ambiguous; keyed by name alone and forgiving, cfhf.py:148's
|
|
9
|
+
* last-one-wins picks a group gated at the wrong scope and the token is refused at first use.
|
|
10
|
+
* The asking ENTRY says which: its `scope` (roles.yaml `groups_scope:`), else its resource.
|
|
11
|
+
*
|
|
12
|
+
* ★ A SERVICE, NOT A TABLE. roles.yaml names groups (`DNS Read`); a role stores IDs. apply-roles.py
|
|
13
|
+
* resolved them per account with a Cloudflare parent credential (cfhf.py:147-148), which agents
|
|
14
|
+
* may not use. So Bao.CloudflareRole asks this service, and the stack chooses the source:
|
|
15
|
+
*
|
|
16
|
+
* a) `permissionGroupsFromEngine` — `GET <mount>/permission-groups` on the Cloudflare engine,
|
|
17
|
+
* answered under the mount's own parent. ★ DEPLOYED AND WIRED 2026-09-15: `bao path-help
|
|
18
|
+
* cloudflare-<account>-platform` lists `^permission-groups$` on plugin v0.0.0-dev, and
|
|
19
|
+
* cloudflare-roles.ts now provides this source to the stack.
|
|
20
|
+
* b) `permissionGroupsFromLiveRoles` — ⚠️ TEMPORARY, AND STILL HERE ON PURPOSE. 2026-09-15: no
|
|
21
|
+
* longer the stack's source, and NOT yet deletable. Two things gate that — (1) the live
|
|
22
|
+
* `result_info` envelope pinned in ../../platform/secrets/vault/plugin/cloudflare/
|
|
23
|
+
* client_result_info.go, whose own header names this deletion as what it gates, and (2)
|
|
24
|
+
* cloudflare-parity.ts, which calls cloudflare-permission-groups-solve.ts directly for the
|
|
25
|
+
* OFFLINE proof against a production export and has no engine to ask. So (b) and the solver
|
|
26
|
+
* stay, one import from being the layer again (cloudflare-roles.ts), until the last step of
|
|
27
|
+
* docs/ledger/openbao-permission-groups-engine-2026-09-15.md is done.
|
|
28
|
+
*
|
|
29
|
+
* ⛔ AN UNKNOWN NAME FAILS THE OPERATION. Dropping it would write a policy one group short, and the
|
|
30
|
+
* engine refuses an empty ID (path_roles.go:181-185) but not a SHORTER list: the minted token is
|
|
31
|
+
* quietly narrower than declared, and the consumer finds out at 403.
|
|
32
|
+
*/
|
|
33
|
+
import * as Context from 'effect/Context';
|
|
34
|
+
import * as Effect from 'effect/Effect';
|
|
35
|
+
import type * as HttpClient from 'effect/unstable/http/HttpClient';
|
|
36
|
+
import { baoRead } from './bao-http.ts';
|
|
37
|
+
import type { BaoError } from './bao-status.ts';
|
|
38
|
+
import { groupKey } from './cloudflare-group-scope.ts';
|
|
39
|
+
import {
|
|
40
|
+
describeProblems,
|
|
41
|
+
observationsOf,
|
|
42
|
+
solveGroups,
|
|
43
|
+
} from './cloudflare-permission-groups-solve.ts';
|
|
44
|
+
import { type WirePolicy, atEveryScope } from './cloudflare-policy.ts';
|
|
45
|
+
import { readCloudflareRole } from './cloudflare-role-wire.ts';
|
|
46
|
+
import type { ExpandedRole } from './cloudflare-roles-expand.ts';
|
|
47
|
+
import { mountPath } from './mount-form.ts';
|
|
48
|
+
|
|
49
|
+
export class CloudflareGroupsError extends Error {
|
|
50
|
+
constructor(
|
|
51
|
+
readonly scope: string,
|
|
52
|
+
readonly problems: readonly string[],
|
|
53
|
+
) {
|
|
54
|
+
super(`Cloudflare permission groups for ${scope}: ${problems.join('; ')}`);
|
|
55
|
+
this.name = 'CloudflareGroupsError';
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** ⛔ Keyed by `groupKey(name, scope)` throughout — see cloudflare-group-scope.ts. */
|
|
60
|
+
type Groups = ReadonlyMap<string, string>;
|
|
61
|
+
type Lookup = Effect.Effect<Groups, BaoError | CloudflareGroupsError, HttpClient.HttpClient>;
|
|
62
|
+
|
|
63
|
+
/** One group a role asks for: its name, and the scope the entry naming it is gated at. */
|
|
64
|
+
export interface GroupRef {
|
|
65
|
+
readonly name: string;
|
|
66
|
+
readonly scope: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export class CloudflarePermissionGroups extends Context.Service<
|
|
70
|
+
CloudflarePermissionGroups,
|
|
71
|
+
{
|
|
72
|
+
/** The ID of every ref, keyed by `groupKey` — or a failure naming what did not resolve. */
|
|
73
|
+
readonly resolve: (
|
|
74
|
+
mount: string,
|
|
75
|
+
refs: readonly GroupRef[],
|
|
76
|
+
) => Effect.Effect<Groups, BaoError | CloudflareGroupsError, HttpClient.HttpClient>;
|
|
77
|
+
}
|
|
78
|
+
>()('homeflare/openbao/CloudflarePermissionGroups') {}
|
|
79
|
+
|
|
80
|
+
const pick = (mount: string, groups: Groups, refs: readonly GroupRef[], hint: string) => {
|
|
81
|
+
const unknown = refs.filter((ref) => !groups.has(groupKey(ref.name, ref.scope)));
|
|
82
|
+
if (unknown.length > 0) {
|
|
83
|
+
const problems = unknown.map(
|
|
84
|
+
(ref) => `unknown group ${JSON.stringify(ref.name)} at scope ${ref.scope} — ${hint}`,
|
|
85
|
+
);
|
|
86
|
+
return Effect.fail(new CloudflareGroupsError(mount, problems));
|
|
87
|
+
}
|
|
88
|
+
return Effect.succeed(
|
|
89
|
+
new Map(
|
|
90
|
+
refs.flatMap((ref) => {
|
|
91
|
+
const key = groupKey(ref.name, ref.scope);
|
|
92
|
+
const id = groups.get(key);
|
|
93
|
+
return id === undefined ? [] : [[key, id] as const];
|
|
94
|
+
}),
|
|
95
|
+
),
|
|
96
|
+
);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
|
|
100
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* ⛔ A 403 IS A MISSING GRANT, AND IT SAYS SO. `BaoError` alone reports `GET /v1/<mount>/
|
|
104
|
+
* permission-groups -> 403: permission denied` — true, and it points nowhere: the path is
|
|
105
|
+
* deployed, the token is valid, and the one thing to change is a policy file. MEASURED 2026-09-15
|
|
106
|
+
* 10:19 on the macmini-admin lane, where `bao write sys/capabilities-self` answered `[deny]` on
|
|
107
|
+
* this exact path. bao-status.ts argues that a failure must name what it is; this is that
|
|
108
|
+
* argument one level up.
|
|
109
|
+
*/
|
|
110
|
+
const deniedHint = (mount: string) =>
|
|
111
|
+
`GET ${mountPath(mount)}/permission-groups answered 403 — this token's policy carries no ` +
|
|
112
|
+
`\`path "${mountPath(mount)}/permission-groups" { capabilities = ["read"] }\` grant. ` +
|
|
113
|
+
'It is declared in ../platform/secrets/vault/policies/homeflare-admin/' +
|
|
114
|
+
'20-cloudflare-permission-groups.hcl and applies with `alchemy deploy` of this stack';
|
|
115
|
+
|
|
116
|
+
const deniedNamed = <A, R>(mount: string, read: Effect.Effect<A, BaoError, R>) =>
|
|
117
|
+
Effect.catchIf(
|
|
118
|
+
read,
|
|
119
|
+
(error) => error.status === 403,
|
|
120
|
+
() => Effect.fail(new CloudflareGroupsError(mount, [deniedHint(mount)])),
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* a) The engine's list, parsed.
|
|
125
|
+
*
|
|
126
|
+
* ★ THE SHAPE IS THE PLUGIN'S OWN, READ FROM ITS SOURCE 2026-09-15:
|
|
127
|
+
* `{"permission_groups":[{"id","name","scopes"}]}` built row by row in
|
|
128
|
+
* ../../platform/secrets/vault/plugin/cloudflare/path_permission_groups.go:42-54. baoRead takes
|
|
129
|
+
* `data` when the body has one and the whole body otherwise (bao-http.ts:160-167), so either
|
|
130
|
+
* envelope parses.
|
|
131
|
+
* ⚠️ PAGINATION IS THE PLUGIN'S PROBLEM, NOT THIS PARSER'S. client_permission_groups.go:52-119 walks
|
|
132
|
+
* Cloudflare's pages and ends every guard in an error rather than a shorter list, so what arrives
|
|
133
|
+
* here is complete or absent. ⛔ ITS `result_info` GUARDS ARE STILL UNMEASURED against a live
|
|
134
|
+
* envelope (client_result_info.go:22-28) — that capture, not this file, is what gates deleting (b).
|
|
135
|
+
* ⛔ ONE NAME WITH TWO IDS IS NOT AN ERROR; ONE (NAME, SCOPE) WITH TWO IDS IS. cfhf.py:148 built
|
|
136
|
+
* `{g["name"]: g["id"]}` and the LAST duplicate won in silence. MEASURED 2026-09-15 on the live
|
|
137
|
+
* list: seven names ARE listed twice, each pair one account-scoped id and one zone-scoped id, so
|
|
138
|
+
* the plugin's "does not choose between them" (path_permission_groups.go:69-70) is the shape in
|
|
139
|
+
* front of us, not a hypothetical. `scopes` tells them apart; the header above says who picks.
|
|
140
|
+
* ⚠️ AN EMPTY `scopes` IS NOT AN ERROR. Such a group is reachable only by the one-id rule below —
|
|
141
|
+
* a name nothing else claims answers wherever it is asked — so a group Cloudflare ships without
|
|
142
|
+
* a scope resolves, and never holds the other 398 hostage.
|
|
143
|
+
*/
|
|
144
|
+
const engineGroups = (mount: string): Lookup =>
|
|
145
|
+
Effect.flatMap(deniedNamed(mount, baoRead(`${mountPath(mount)}/permission-groups`)), (data) => {
|
|
146
|
+
const list = data?.['permission_groups'];
|
|
147
|
+
if (data === undefined) {
|
|
148
|
+
// ⚠️ 404 IS NOW A MOUNT-SHAPED ANSWER, NOT THE EXPECTED ONE. The path IS deployed
|
|
149
|
+
// estate-wide (measured 2026-09-15), so a 404 here means this mount is served by an older
|
|
150
|
+
// plugin binary or BAO_ADDR is not the mini — never "no groups".
|
|
151
|
+
const why = 'GET <mount>/permission-groups answered 404 — the endpoint is not deployed here';
|
|
152
|
+
return Effect.fail(new CloudflareGroupsError(mount, [why]));
|
|
153
|
+
}
|
|
154
|
+
if (!Array.isArray(list)) {
|
|
155
|
+
return Effect.fail(new CloudflareGroupsError(mount, ['no permission_groups array in reply']));
|
|
156
|
+
}
|
|
157
|
+
const scoped = new Map<string, string>();
|
|
158
|
+
const idsOf = new Map<string, Set<string>>();
|
|
159
|
+
const problems: string[] = [];
|
|
160
|
+
for (const item of list) {
|
|
161
|
+
const name = isRecord(item) ? item['name'] : undefined;
|
|
162
|
+
const id = isRecord(item) ? item['id'] : undefined;
|
|
163
|
+
const scopes = isRecord(item) ? item['scopes'] : undefined;
|
|
164
|
+
if (
|
|
165
|
+
typeof name !== 'string' ||
|
|
166
|
+
typeof id !== 'string' ||
|
|
167
|
+
!Array.isArray(scopes) ||
|
|
168
|
+
!scopes.every((scope) => typeof scope === 'string')
|
|
169
|
+
) {
|
|
170
|
+
problems.push('an entry without a string name, id and scopes');
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
idsOf.set(name, (idsOf.get(name) ?? new Set()).add(id));
|
|
174
|
+
for (const scope of scopes) {
|
|
175
|
+
const key = groupKey(name, scope);
|
|
176
|
+
if (scoped.has(key) && scoped.get(key) !== id) {
|
|
177
|
+
problems.push(
|
|
178
|
+
`ambiguous: ${JSON.stringify(name)} is listed with two ids at scope ${scope}`,
|
|
179
|
+
);
|
|
180
|
+
} else {
|
|
181
|
+
scoped.set(key, id);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* ★ A NAME WITH ONE ID NEEDS NO SCOPE, and answers at every scope an entry can ask for: there
|
|
187
|
+
* is nothing to choose between, and 385 of the list's 392 names are in that case. It is only
|
|
188
|
+
* the seven two-id names the asking scope has to separate — and for those, a scope the name
|
|
189
|
+
* is not listed at resolves to nothing and fails by name, never to the other scope's id.
|
|
190
|
+
*/
|
|
191
|
+
const sole = new Map(
|
|
192
|
+
[...idsOf].flatMap(([name, ids]) => {
|
|
193
|
+
const only = [...ids];
|
|
194
|
+
return ids.size === 1 && only[0] !== undefined ? [[name, only[0]] as const] : [];
|
|
195
|
+
}),
|
|
196
|
+
);
|
|
197
|
+
return problems.length > 0
|
|
198
|
+
? Effect.fail(new CloudflareGroupsError(mount, problems))
|
|
199
|
+
: Effect.succeed(new Map([...atEveryScope(sole), ...scoped]));
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* ★ ONE FETCH PER MOUNT PER RUN, not one per role — 586 roles would otherwise ask 586 times.
|
|
204
|
+
* ⚠️ A FAILURE IS CACHED TOO (Effect.cached keeps the Exit), so one sealed-vault 503 fails every
|
|
205
|
+
* role on that mount for the rest of the run. That is the honest outcome for a plan: retrying
|
|
206
|
+
* 586 times against a sealed server would only make the failure slower.
|
|
207
|
+
*/
|
|
208
|
+
export const permissionGroupsFromEngine = () =>
|
|
209
|
+
Effect.sync(() => {
|
|
210
|
+
const cache = new Map<string, Lookup>();
|
|
211
|
+
const cachedFor = (mount: string) =>
|
|
212
|
+
Effect.suspend(() => {
|
|
213
|
+
const hit = cache.get(mountPath(mount));
|
|
214
|
+
if (hit !== undefined) return hit;
|
|
215
|
+
return Effect.flatMap(Effect.cached(engineGroups(mount)), (lookup) => {
|
|
216
|
+
cache.set(mountPath(mount), lookup);
|
|
217
|
+
return lookup;
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
return CloudflarePermissionGroups.of({
|
|
221
|
+
resolve: (mount, refs) =>
|
|
222
|
+
Effect.flatMap(cachedFor(mount), (groups) =>
|
|
223
|
+
pick(mount, groups, refs, 'the engine does not list it for this account at that scope'),
|
|
224
|
+
),
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* b) ⚠️ TEMPORARY, AND NO LONGER THE STACK'S SOURCE SINCE 2026-09-15 — see the header for the two
|
|
230
|
+
* things that gate deleting it. Reads every DECLARED role of an account — no LIST, because an
|
|
231
|
+
* undeclared live role carries IDs with no names to pair them with — and solves the account at
|
|
232
|
+
* once.
|
|
233
|
+
* 🔴 WHAT RETIRED IT, 2026-09-15 10:13: `alchemy deploy` could not create
|
|
234
|
+
* `cloudflare-{<account>,testing}-platform/roles/r2-backup-mini` — `unknown group "Workers R2
|
|
235
|
+
* Storage Bucket Item Write" — no live role on account <account> carries it; a NEW group needs
|
|
236
|
+
* the engine endpoint`. That is this source's documented blind spot arriving, not a bug in it.
|
|
237
|
+
*
|
|
238
|
+
* ★ PER ACCOUNT, NOT PER MOUNT, BECAUSE ONE MOUNT CANNOT SOLVE ITSELF. MEASURED on the snapshot:
|
|
239
|
+
* `aimto-app-alchemy-os-create` on the platform mount pairs `Email Routing Rules Write` and
|
|
240
|
+
* `DNS Write` with two unknown IDs, and neither group appears alone on that mount. The dns and
|
|
241
|
+
* security mounts of the same account isolate them. apply-roles.py's map was per account too
|
|
242
|
+
* (apply-roles.py:117).
|
|
243
|
+
*/
|
|
244
|
+
export const permissionGroupsFromLiveRoles = (
|
|
245
|
+
declared: ReadonlyMap<string, readonly ExpandedRole[]>,
|
|
246
|
+
) =>
|
|
247
|
+
Effect.gen(function* () {
|
|
248
|
+
const accountOf = new Map<string, string>();
|
|
249
|
+
const byAccount = new Map<string, ExpandedRole[]>();
|
|
250
|
+
for (const [mount, roles] of declared) {
|
|
251
|
+
for (const role of roles) {
|
|
252
|
+
accountOf.set(mount, role.account);
|
|
253
|
+
byAccount.set(role.account, [...(byAccount.get(role.account) ?? []), role]);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
const solved = new Map<string, Lookup>();
|
|
257
|
+
for (const [account, roles] of byAccount) {
|
|
258
|
+
const solve = Effect.gen(function* () {
|
|
259
|
+
const live = new Map<string, readonly WirePolicy[]>();
|
|
260
|
+
// ★ `Effect.all` over mapped reads, not `Effect.forEach`: oxlint's unicorn/no-array-for-each
|
|
261
|
+
// matches the method NAME and cannot tell Effect's from Array's.
|
|
262
|
+
yield* Effect.all(
|
|
263
|
+
roles.map((role) =>
|
|
264
|
+
Effect.map(readCloudflareRole(role.mount, role.name), (read) => {
|
|
265
|
+
if (read?.policies !== undefined) {
|
|
266
|
+
live.set(`${role.mount}/${role.name}`, read.policies);
|
|
267
|
+
}
|
|
268
|
+
}),
|
|
269
|
+
),
|
|
270
|
+
{ concurrency: 8, discard: true },
|
|
271
|
+
);
|
|
272
|
+
const result = solveGroups(observationsOf(roles, live));
|
|
273
|
+
const problems = describeProblems(result);
|
|
274
|
+
if (problems.length > 0) {
|
|
275
|
+
return yield* Effect.fail(new CloudflareGroupsError(`account ${account}`, problems));
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* ⚠️ IT ANSWERS AT EVERY SCOPE BECAUSE IT CANNOT SEE ONE. A live role carries ids and no
|
|
279
|
+
* scopes, so the solver learns `name → id` and nothing more; the seven two-id names
|
|
280
|
+
* resolve here to whichever id the estate happens to carry, at whatever scope is asked.
|
|
281
|
+
* That is this source's blind spot, not a second opinion — (a) keys by (name, scope).
|
|
282
|
+
*/
|
|
283
|
+
return atEveryScope(result.groups);
|
|
284
|
+
});
|
|
285
|
+
solved.set(account, yield* Effect.cached(solve));
|
|
286
|
+
}
|
|
287
|
+
return CloudflarePermissionGroups.of({
|
|
288
|
+
resolve: (mount, refs) => {
|
|
289
|
+
const account = accountOf.get(mountPath(mount));
|
|
290
|
+
const lookup = account === undefined ? undefined : solved.get(account);
|
|
291
|
+
if (account === undefined || lookup === undefined) {
|
|
292
|
+
const why = 'no declared role on this mount, so no live role can vouch for its groups';
|
|
293
|
+
return Effect.fail(new CloudflareGroupsError(mount, [why]));
|
|
294
|
+
}
|
|
295
|
+
const hint = `no live role on account ${account} carries it; a NEW group needs the engine endpoint`;
|
|
296
|
+
return Effect.flatMap(lookup, (groups) => pick(mount, groups, refs, hint));
|
|
297
|
+
},
|
|
298
|
+
});
|
|
299
|
+
});
|