@homeflare/alchemy 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/README.md +54 -10
  2. package/dist/caddy/admin-calls.d.ts +48 -0
  3. package/dist/caddy/admin-calls.d.ts.map +1 -0
  4. package/dist/caddy/admin-guard.d.ts +40 -0
  5. package/dist/caddy/admin-guard.d.ts.map +1 -0
  6. package/dist/caddy/admin.d.ts +73 -0
  7. package/dist/caddy/admin.d.ts.map +1 -0
  8. package/dist/caddy/caddyfile-tripwire.d.ts +5 -0
  9. package/dist/caddy/caddyfile-tripwire.d.ts.map +1 -0
  10. package/dist/caddy/config-form.d.ts +27 -0
  11. package/dist/caddy/config-form.d.ts.map +1 -0
  12. package/dist/caddy/config-lifecycle.d.ts +45 -0
  13. package/dist/caddy/config-lifecycle.d.ts.map +1 -0
  14. package/dist/caddy/config.d.ts +30 -0
  15. package/dist/caddy/config.d.ts.map +1 -0
  16. package/dist/caddy/digest.d.ts +7 -0
  17. package/dist/caddy/digest.d.ts.map +1 -0
  18. package/dist/caddy/fake-caddy.d.ts +43 -0
  19. package/dist/caddy/fake-caddy.d.ts.map +1 -0
  20. package/dist/caddy/index.d.ts +21 -0
  21. package/dist/caddy/index.d.ts.map +1 -0
  22. package/dist/caddy/index.js +912 -0
  23. package/dist/caddy/index.js.map +28 -0
  24. package/dist/caddy/local-admin.d.ts +45 -0
  25. package/dist/caddy/local-admin.d.ts.map +1 -0
  26. package/dist/caddy/providers.d.ts +14 -0
  27. package/dist/caddy/providers.d.ts.map +1 -0
  28. package/dist/caddy/with-file.d.ts +47 -0
  29. package/dist/caddy/with-file.d.ts.map +1 -0
  30. package/dist/cloudflare/fake-mesh.d.ts +58 -0
  31. package/dist/cloudflare/fake-mesh.d.ts.map +1 -0
  32. package/dist/cloudflare/index.d.ts +9 -3
  33. package/dist/cloudflare/index.d.ts.map +1 -1
  34. package/dist/cloudflare/index.js +218 -7
  35. package/dist/cloudflare/index.js.map +9 -4
  36. package/dist/cloudflare/mesh-node-api.d.ts +75 -0
  37. package/dist/cloudflare/mesh-node-api.d.ts.map +1 -0
  38. package/dist/cloudflare/mesh-node-form.d.ts +82 -0
  39. package/dist/cloudflare/mesh-node-form.d.ts.map +1 -0
  40. package/dist/cloudflare/mesh-node-harness.d.ts +46 -0
  41. package/dist/cloudflare/mesh-node-harness.d.ts.map +1 -0
  42. package/dist/cloudflare/mesh-node-lifecycle.d.ts +22 -0
  43. package/dist/cloudflare/mesh-node-lifecycle.d.ts.map +1 -0
  44. package/dist/cloudflare/mesh-node-token.d.ts +23 -0
  45. package/dist/cloudflare/mesh-node-token.d.ts.map +1 -0
  46. package/dist/cloudflare/mesh-node.d.ts +48 -0
  47. package/dist/cloudflare/mesh-node.d.ts.map +1 -0
  48. package/dist/cloudflare/providers.d.ts +1 -12
  49. package/dist/cloudflare/providers.d.ts.map +1 -1
  50. package/dist/index.d.ts +1 -0
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/launchd/index.js.map +2 -2
  53. package/dist/launchd/secret-tripwire.d.ts +1 -0
  54. package/dist/launchd/secret-tripwire.d.ts.map +1 -1
  55. package/dist/openbao/cloudflare-role.d.ts.map +1 -1
  56. package/dist/openbao/fake-engines.d.ts +30 -0
  57. package/dist/openbao/fake-engines.d.ts.map +1 -0
  58. package/dist/openbao/fake-stack.d.ts +29 -0
  59. package/dist/openbao/fake-stack.d.ts.map +1 -0
  60. package/dist/openbao/index.js +231 -151
  61. package/dist/openbao/index.js.map +9 -7
  62. package/dist/openbao/policy.d.ts +2 -0
  63. package/dist/openbao/policy.d.ts.map +1 -1
  64. package/dist/openbao/proxmox-role-form.d.ts +18 -8
  65. package/dist/openbao/proxmox-role-form.d.ts.map +1 -1
  66. package/dist/openbao/proxmox-role-wire.d.ts +18 -0
  67. package/dist/openbao/proxmox-role-wire.d.ts.map +1 -0
  68. package/dist/openbao/proxmox-role.d.ts.map +1 -1
  69. package/dist/openbao/rename.d.ts +106 -0
  70. package/dist/openbao/rename.d.ts.map +1 -0
  71. package/docs/caddy.md +180 -0
  72. package/docs/mesh-node.md +188 -0
  73. package/package.json +7 -2
  74. package/src/caddy/admin-calls.ts +128 -0
  75. package/src/caddy/admin-guard.ts +170 -0
  76. package/src/caddy/admin.ts +74 -0
  77. package/src/caddy/caddyfile-tripwire.ts +133 -0
  78. package/src/caddy/config-form.ts +64 -0
  79. package/src/caddy/config-lifecycle.test.ts +218 -0
  80. package/src/caddy/config-lifecycle.ts +148 -0
  81. package/src/caddy/config.ts +116 -0
  82. package/src/caddy/digest.ts +44 -0
  83. package/src/caddy/fake-caddy.ts +160 -0
  84. package/src/caddy/guards.test.ts +156 -0
  85. package/src/caddy/index.ts +25 -0
  86. package/src/caddy/local-admin.test.ts +160 -0
  87. package/src/caddy/local-admin.ts +189 -0
  88. package/src/caddy/providers.test.ts +244 -0
  89. package/src/caddy/providers.ts +17 -0
  90. package/src/caddy/with-file.ts +62 -0
  91. package/src/cloudflare/fake-mesh.ts +181 -0
  92. package/src/cloudflare/index.ts +9 -3
  93. package/src/cloudflare/mesh-node-api.ts +127 -0
  94. package/src/cloudflare/mesh-node-edges.test.ts +103 -0
  95. package/src/cloudflare/mesh-node-form.test.ts +80 -0
  96. package/src/cloudflare/mesh-node-form.ts +115 -0
  97. package/src/cloudflare/mesh-node-harness.ts +124 -0
  98. package/src/cloudflare/mesh-node-lifecycle.ts +187 -0
  99. package/src/cloudflare/mesh-node-policy.test.ts +151 -0
  100. package/src/cloudflare/mesh-node-token.test.ts +117 -0
  101. package/src/cloudflare/mesh-node-token.ts +125 -0
  102. package/src/cloudflare/mesh-node.test.ts +172 -0
  103. package/src/cloudflare/mesh-node.ts +120 -0
  104. package/src/cloudflare/providers.ts +20 -2
  105. package/src/index.ts +1 -0
  106. package/src/launchd/secret-tripwire.ts +1 -1
  107. package/src/openbao/REPLACE.md +97 -21
  108. package/src/openbao/cloudflare-role-wire.test.ts +8 -34
  109. package/src/openbao/cloudflare-role.ts +30 -8
  110. package/src/openbao/fake-engines.ts +119 -0
  111. package/src/openbao/fake-stack.ts +112 -0
  112. package/src/openbao/policy-rename.test.ts +112 -0
  113. package/src/openbao/policy-wire.test.ts +7 -26
  114. package/src/openbao/policy.ts +27 -4
  115. package/src/openbao/proxmox-role-form.ts +27 -8
  116. package/src/openbao/proxmox-role-wire.ts +24 -0
  117. package/src/openbao/proxmox-role.ts +62 -37
  118. package/src/openbao/rename-occupied.test.ts +166 -0
  119. package/src/openbao/rename.ts +162 -0
  120. package/src/openbao/role-rename.test.ts +205 -0
@@ -77,6 +77,8 @@ export interface BaoPolicy extends Resource<'Bao.Policy', BaoPolicyProps, BaoPol
77
77
  * orphaned resource; a caller that means it opts in with `.pipe(RemovalPolicy.destroy())`. This
78
78
  * is Terraform's `prevent_destroy`, not a stubbed operation — a delete that silently did nothing
79
79
  * would lie to whoever read the plan.
80
+ * ⚠️ `retain` ALSO KEEPS THE OLD GENERATION OF A RENAME. A new `name` plans `replace`, and under
81
+ * `retain` the old policy is left live and unmanaged (rename.ts, REPLACE.md).
80
82
  */
81
83
  export declare const BaoPolicy: import("alchemy").ResourceClass<BaoPolicy>;
82
84
  export declare const BaoPolicyProvider: () => import("effect/Layer").Layer<Provider.Provider<BaoPolicy>, never, FileSystem.FileSystem | HttpClient.HttpClient | Path.Path>;
@@ -1 +1 @@
1
- {"version":3,"file":"policy.d.ts","sourceRoot":"","sources":["../../src/openbao/policy.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAE7C,OAAO,KAAK,UAAU,MAAM,mBAAmB,CAAC;AAChD,OAAO,KAAK,IAAI,MAAM,aAAa,CAAC;AACpC,OAAO,KAAK,KAAK,UAAU,MAAM,iCAAiC,CAAC;AAKnE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,MAAM,WAAW,cAAc;IAC7B,0DAA0D;IAC1D,IAAI,EAAE,MAAM,CAAC;IACb;;;;;;OAMG;IACH,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,mBAAmB;IAClC,mBAAmB;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,yEAAyE;IACzE,MAAM,EAAE,MAAM,CAAC;IACf,qEAAqE;IACrE,MAAM,EAAE,MAAM,CAAC;IACf,2EAA2E;IAC3E,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,SAAU,SAAQ,QAAQ,CACzC,YAAY,EACZ,cAAc,EACd,mBAAmB,EACnB,KAAK,EACL,UAAU,CAAC,UAAU,GAAG,UAAU,CAAC,UAAU,GAAG,IAAI,CAAC,IAAI,CAC1D;CAAG;AAEJ;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,eAAO,MAAM,SAAS,4CAEpB,CAAC;AAYH,eAAO,MAAM,iBAAiB,oIAuG3B,CAAC"}
1
+ {"version":3,"file":"policy.d.ts","sourceRoot":"","sources":["../../src/openbao/policy.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAE7C,OAAO,KAAK,UAAU,MAAM,mBAAmB,CAAC;AAChD,OAAO,KAAK,IAAI,MAAM,aAAa,CAAC;AACpC,OAAO,KAAK,KAAK,UAAU,MAAM,iCAAiC,CAAC;AAMnE;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,MAAM,WAAW,cAAc;IAC7B,0DAA0D;IAC1D,IAAI,EAAE,MAAM,CAAC;IACb;;;;;;OAMG;IACH,SAAS,EAAE,MAAM,CAAC;CACnB;AAED,MAAM,WAAW,mBAAmB;IAClC,mBAAmB;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,yEAAyE;IACzE,MAAM,EAAE,MAAM,CAAC;IACf,qEAAqE;IACrE,MAAM,EAAE,MAAM,CAAC;IACf,2EAA2E;IAC3E,KAAK,EAAE,MAAM,CAAC;CACf;AAED,MAAM,WAAW,SAAU,SAAQ,QAAQ,CACzC,YAAY,EACZ,cAAc,EACd,mBAAmB,EACnB,KAAK,EACL,UAAU,CAAC,UAAU,GAAG,UAAU,CAAC,UAAU,GAAG,IAAI,CAAC,IAAI,CAC1D;CAAG;AAEJ;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,eAAO,MAAM,SAAS,4CAEpB,CAAC;AAYH,eAAO,MAAM,iBAAiB,oIA2H3B,CAAC"}
@@ -63,18 +63,18 @@ export interface BaoProxmoxRoleAttributes {
63
63
  }
64
64
  /** `proxmox-tb4/roles/read` — read, write and delete all address the role here. */
65
65
  export declare const rolePath: (mount: string, name: string) => string;
66
- export declare const attributesOf: (props: BaoProxmoxRoleProps, live: Record<string, unknown>) => BaoProxmoxRoleAttributes;
66
+ export declare const attributesOf: (props: Pick<BaoProxmoxRoleProps, 'mount' | 'name'>, live: Record<string, unknown>) => BaoProxmoxRoleAttributes;
67
67
  /**
68
68
  * The body for `PUT <mount>/roles/<name>` — all strings, the `k=v` pairs `bao write` sent — or
69
69
  * the prop names whose duration would not parse.
70
70
  *
71
- * ⛔ A WRITE TO roles/{name} REPLACES THE ROLE, IT DOES NOT MERGE. An omitted `ttl` is written
72
- * as the field's zero value, so a resource that treated undeclared TTLs as "unmanaged" — the
73
- * Bao.Mount shape — would silently reset a lease ceiling every time it rewrote the role for an
74
- * unrelated reason. Both TTLs are therefore required props and EVERY write carries all three
75
- * fields. REASONED from the Vault secrets-framework write semantics plus this plugin's schema
76
- * marking only `mint_user` required; NOT measured, because this session was read-only and
77
- * proving it would have meant writing to the live engine.
71
+ * ⛔ EVERY WRITE CARRIES ALL THREE FIELDS, and both TTLs are required props, so `matches` covers
72
+ * the whole role and no field is left unmanaged behind a green plan.
73
+ * ⚠️ CORRECTED 2026-09-21 FROM THE PLUGIN SOURCE. This said a write REPLACES the role and resets an
74
+ * omitted TTL to zero. The port in homeflare-openbao-plugins (secrets/proxmox/path_roles.go,
75
+ * pathRolesWrite, commit 0e79154) MERGES: it loads the stored role and sets only the fields
76
+ * present (`d.GetOk`). An omitted TTL would keep its old value, which is still a value this
77
+ * resource would not be managing. The build running live was not re-checked against that source.
78
78
  *
79
79
  * ⚠️ Seconds go on the wire as integers, not as `1h`. The schema declares both TTLs
80
80
  * `"format": "seconds"`, and an integer is accepted unambiguously by every path that reads
@@ -87,6 +87,16 @@ export declare const writeBody: (props: BaoProxmoxRoleProps) => {
87
87
  ok: false;
88
88
  bad: string[];
89
89
  };
90
+ /**
91
+ * Why moving the role's mint user from `before` to the declared one must be refused, or undefined.
92
+ * The ⛔ on reconcile in proxmox-role.ts says why a re-scope needs `allowMintUserChange`.
93
+ *
94
+ * ★ ONE DEFINITION FOR BOTH PLACES THAT ASK. Reconcile asks it of the live role at the same path.
95
+ * The diff asks it across a rename, where `before` is the role being replaced: a rename writes a
96
+ * brand-new role, so reconcile alone would see nothing to re-scope, and a rename would become the
97
+ * way around the guard.
98
+ */
99
+ export declare const rescopeRefusal: (before: string, mintUser: string, allowMintUserChange: string | undefined) => string | undefined;
90
100
  /** True when the live role already matches the declaration on every field the plugin stores. */
91
101
  export declare const matches: (attributes: BaoProxmoxRoleAttributes, props: BaoProxmoxRoleProps) => boolean;
92
102
  //# sourceMappingURL=proxmox-role-form.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"proxmox-role-form.d.ts","sourceRoot":"","sources":["../../src/openbao/proxmox-role-form.ts"],"names":[],"mappings":"AAyBA,MAAM,WAAW,mBAAmB;IAClC,8FAA8F;IAC9F,KAAK,EAAE,MAAM,CAAC;IACd;;;;;;OAMG;IACH,IAAI,EAAE,MAAM,CAAC;IACb;;;;;;;OAOG;IACH,QAAQ,EAAE,MAAM,CAAC;IACjB,yFAAyF;IACzF,GAAG,EAAE,MAAM,CAAC;IACZ;;;;;;;;;OASG;IACH,MAAM,EAAE,MAAM,CAAC;IACf;;;;;;;;;;;;;OAaG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED,MAAM,WAAW,wBAAwB;IACvC,2CAA2C;IAC3C,KAAK,EAAE,MAAM,CAAC;IACd,iBAAiB;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,qFAAqF;IACrF,QAAQ,EAAE,MAAM,CAAC;IACjB,+EAA+E;IAC/E,GAAG,EAAE,MAAM,CAAC;IACZ,0CAA0C;IAC1C,MAAM,EAAE,MAAM,CAAC;IACf,sEAAsE;IACtE,MAAM,EAAE,MAAM,CAAC;CAChB;AAmBD,mFAAmF;AACnF,eAAO,MAAM,QAAQ,UAAW,MAAM,QAAQ,MAAM,WAAwC,CAAC;AAE7F,eAAO,MAAM,YAAY,UAChB,mBAAmB,QACpB,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAC5B,wBAYF,CAAC;AAEF;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,SAAS,UACb,mBAAmB,KACzB;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,GAAG,EAAE,MAAM,EAAE,CAAA;CAWzE,CAAC;AAaF,gGAAgG;AAChG,eAAO,MAAM,OAAO,eAAgB,wBAAwB,SAAS,mBAAmB,YAG9C,CAAC"}
1
+ {"version":3,"file":"proxmox-role-form.d.ts","sourceRoot":"","sources":["../../src/openbao/proxmox-role-form.ts"],"names":[],"mappings":"AAyBA,MAAM,WAAW,mBAAmB;IAClC,8FAA8F;IAC9F,KAAK,EAAE,MAAM,CAAC;IACd;;;;;;OAMG;IACH,IAAI,EAAE,MAAM,CAAC;IACb;;;;;;;OAOG;IACH,QAAQ,EAAE,MAAM,CAAC;IACjB,yFAAyF;IACzF,GAAG,EAAE,MAAM,CAAC;IACZ;;;;;;;;;OASG;IACH,MAAM,EAAE,MAAM,CAAC;IACf;;;;;;;;;;;;;OAaG;IACH,mBAAmB,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED,MAAM,WAAW,wBAAwB;IACvC,2CAA2C;IAC3C,KAAK,EAAE,MAAM,CAAC;IACd,iBAAiB;IACjB,IAAI,EAAE,MAAM,CAAC;IACb,qFAAqF;IACrF,QAAQ,EAAE,MAAM,CAAC;IACjB,+EAA+E;IAC/E,GAAG,EAAE,MAAM,CAAC;IACZ,0CAA0C;IAC1C,MAAM,EAAE,MAAM,CAAC;IACf,sEAAsE;IACtE,MAAM,EAAE,MAAM,CAAC;CAChB;AAmBD,mFAAmF;AACnF,eAAO,MAAM,QAAQ,UAAW,MAAM,QAAQ,MAAM,WAAwC,CAAC;AAE7F,eAAO,MAAM,YAAY,UAChB,IAAI,CAAC,mBAAmB,EAAE,OAAO,GAAG,MAAM,CAAC,QAC5C,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,KAC5B,wBAYF,CAAC;AAEF;;;;;;;;;;;;;;;GAeG;AACH,eAAO,MAAM,SAAS,UACb,mBAAmB,KACzB;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAA;CAAE,GAAG;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,GAAG,EAAE,MAAM,EAAE,CAAA;CAWzE,CAAC;AAaF;;;;;;;;GAQG;AACH,eAAO,MAAM,cAAc,WACjB,MAAM,YACJ,MAAM,uBACK,MAAM,GAAG,SAAS,KACtC,MAAM,GAAG,SAIyF,CAAC;AAEtG,gGAAgG;AAChG,eAAO,MAAM,OAAO,eAAgB,wBAAwB,SAAS,mBAAmB,YAG9C,CAAC"}
@@ -0,0 +1,18 @@
1
+ /**
2
+ * The read Bao.ProxmoxRole makes of `<mount>/roles/<name>`, split from proxmox-role.ts so the diff,
3
+ * reconcile and the rename's re-scope check share one definition of "the live role".
4
+ */
5
+ import * as Effect from 'effect/Effect';
6
+ import { type BaoProxmoxRoleProps } from './proxmox-role-form.ts';
7
+ /**
8
+ * The live role, or undefined when OpenBao answers 404.
9
+ *
10
+ * ⚠️ A missing MOUNT and a missing ROLE both read as undefined here, because both answer 404:
11
+ * an absent role is a read that found nothing, and a path under no mount is OpenBao's
12
+ * unsupported-path error, which is also a 404 (sdk/logical/response_util.go). They are not the
13
+ * same failure — one is a typo in `mount`, the other is a role that has yet to be created — so
14
+ * reconcile lets OpenBao's own `errors` through on the write rather than inventing a message.
15
+ * A bad mount says "no handler for route".
16
+ */
17
+ export declare const readRole: (props: Pick<BaoProxmoxRoleProps, 'mount' | 'name'>) => Effect.Effect<import("./proxmox-role-form.ts").BaoProxmoxRoleAttributes | undefined, import("./bao-status.ts").BaoError, import("effect/unstable/http/HttpClient").HttpClient>;
18
+ //# sourceMappingURL=proxmox-role-wire.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"proxmox-role-wire.d.ts","sourceRoot":"","sources":["../../src/openbao/proxmox-role-wire.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AAExC,OAAO,EAAE,KAAK,mBAAmB,EAA0B,MAAM,wBAAwB,CAAC;AAE1F;;;;;;;;;GASG;AACH,eAAO,MAAM,QAAQ,UAAW,IAAI,CAAC,mBAAmB,EAAE,OAAO,GAAG,MAAM,CAAC,mLAKvE,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"proxmox-role.d.ts","sourceRoot":"","sources":["../../src/openbao/proxmox-role.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAE7C,OAAO,KAAK,KAAK,UAAU,MAAM,iCAAiC,CAAC;AAEnE,OAAO,EACL,KAAK,wBAAwB,EAC7B,KAAK,mBAAmB,EAKzB,MAAM,wBAAwB,CAAC;AAEhC,YAAY,EAAE,wBAAwB,EAAE,mBAAmB,EAAE,CAAC;AAE9D,MAAM,WAAW,cAAe,SAAQ,QAAQ,CAC9C,iBAAiB,EACjB,mBAAmB,EACnB,wBAAwB,EACxB,KAAK,EACL,UAAU,CAAC,UAAU,CACtB;CAAG;AAEJ,eAAO,MAAM,cAAc,iDAEzB,CAAC;AAiBH,eAAO,MAAM,sBAAsB,qGAoIhC,CAAC"}
1
+ {"version":3,"file":"proxmox-role.d.ts","sourceRoot":"","sources":["../../src/openbao/proxmox-role.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAoCG;AACH,OAAO,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AAEnC,OAAO,KAAK,QAAQ,MAAM,kBAAkB,CAAC;AAE7C,OAAO,KAAK,KAAK,UAAU,MAAM,iCAAiC,CAAC;AAEnE,OAAO,EACL,KAAK,wBAAwB,EAC7B,KAAK,mBAAmB,EAKzB,MAAM,wBAAwB,CAAC;AAYhC,YAAY,EAAE,wBAAwB,EAAE,mBAAmB,EAAE,CAAC;AAE9D,MAAM,WAAW,cAAe,SAAQ,QAAQ,CAC9C,iBAAiB,EACjB,mBAAmB,EACnB,wBAAwB,EACxB,KAAK,EACL,UAAU,CAAC,UAAU,CACtB;CAAG;AAEJ,eAAO,MAAM,cAAc,iDAEzB,CAAC;AAEH,eAAO,MAAM,sBAAsB,qGAkKhC,CAAC"}
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Rename safety for the families whose identity is a name, or a mount and a name: Bao.Policy,
3
+ * Bao.CloudflareRole and Bao.ProxmoxRole. REPLACE.md has the audit and what each answer does.
4
+ *
5
+ * ⛔ THE BUG THIS CLOSES, REPRODUCED 2026-09-21 AGAINST alchemy@2.0.0-beta.79's OWN PLAN AND APPLY
6
+ * (fake-stack.ts): a Bao.Policy deployed as `old`, then redeclared as `new` under
7
+ * `RemovalPolicy.destroy()`, planned `update` and left BOTH policies live. diff read the NEW
8
+ * path, found nothing, answered `update`, and reconcile wrote the new object. The old one stayed
9
+ * live under no state record, so no later plan would mention it, and not even `destroy` could
10
+ * remove it.
11
+ * ★ A CHANGED IDENTITY IS A DIFFERENT OBJECT, SO THE ANSWER IS `replace`. Create-first
12
+ * (`deleteFirst` false): the new path never collides with the old, the new generation is written,
13
+ * dependents are updated in the same graph, and only then is the old generation deleted. The
14
+ * engine hands that delete the OLD generation's attributes (Apply.ts deleteOldGenerations and the
15
+ * GC pass both pass `output: old.attr`), and every delete here reads `output`, never `news`.
16
+ * ⛔ A new path that collides with ANOTHER live object is refused at plan instead (`judgeMove`).
17
+ * ⚠️ UNDER `retain` THE OLD OBJECT STAYS LIVE. Apply.ts:2164-2173 skips the old generation's delete
18
+ * and logs "Retaining replaced resource (removal policy: retain)". The plan still says `replace`
19
+ * and the apply says what it kept, so it is no longer silent, but the old object is unmanaged
20
+ * from then on. Remove it by hand, or opt into `RemovalPolicy.destroy()`.
21
+ */
22
+ import * as Effect from 'effect/Effect';
23
+ /**
24
+ * A string prop of a diff's `news`, once that one prop is resolved; `undefined` while it is still an
25
+ * Output of a changing upstream (a function-typed proxy) or an Effect.
26
+ *
27
+ * ★ THE IDENTITY IS JUDGED BEFORE `isResolved(news)`. That guard returns `undefined` when ANY prop is
28
+ * pending, and the engine then falls back to `update` (Plan.ts, `havePropsChanged`). A rename that
29
+ * lands in the same deploy as a pending `fragments` or `policies` Output would slip through as
30
+ * `update`, which is the orphan again. launchd/host-effect.ts reads its identity the same way.
31
+ */
32
+ export declare const declaredString: (news: unknown, key: string) => string | undefined;
33
+ /** True while an optional prop is declared but is not yet a value: an Output of a changing upstream. */
34
+ export declare const isPendingProp: (news: unknown, key: string) => boolean;
35
+ /** A role's API path from a diff's `news`, once both `mount` and `name` are resolved. */
36
+ export declare const declaredRolePath: (news: unknown, rolePath: (mount: string, name: string) => string) => string | undefined;
37
+ /**
38
+ * How OpenBao keys an ACL policy: trimmed and lowercased. openbao v2.6.2
39
+ * vault/policy/policy_store.go:698-700 (`sanitizeName`), applied by SetPolicy (:268), GetPolicy
40
+ * (:378) and DeletePolicy (:562).
41
+ *
42
+ * ⛔ SO `Admin` → `admin` IS THE SAME POLICY, NOT A RENAME. A `replace` there would write it, and then,
43
+ * under `destroy`, delete the old generation, which is that same policy. Every grant it carries
44
+ * would be revoked by a change of case.
45
+ */
46
+ export declare const policyKey: (name: string) => string;
47
+ /**
48
+ * Whether the declared identity names a different object than the one in state: `true` → moved (the
49
+ * diff asks `judgeMove`, which also reads the target); `false` → the same object; `undefined` → not
50
+ * knowable yet, because the declared side is still an Output. `key` is how the SERVER keys the object (`policyKey`); the default is exact,
51
+ * which is right for a mount path (vault/routing/router.go folds no case) and for both plugins'
52
+ * roles (each stores `roles/<name>` verbatim).
53
+ *
54
+ * ⛔ NEVER `replace` ON AN UNKNOWN IDENTITY. The Output may resolve to the same path, and a replace
55
+ * onto the same path deletes, under `destroy`, the object it has just written. The diff defers
56
+ * instead. If the engine then plans `update` and the identity turns out to have changed,
57
+ * `refuseMovedUpdate` stops reconcile before it writes anything.
58
+ */
59
+ export declare const isMoved: (stored: string, declared: string | undefined, key?: (identity: string) => string) => boolean | undefined;
60
+ /**
61
+ * The role path a resource last wrote, or last tried to: its attributes once a write finished, else
62
+ * the props of the create or replacement that did not (`output` is undefined for both).
63
+ */
64
+ export declare const triedRolePath: (output: {
65
+ readonly mount: string;
66
+ readonly name: string;
67
+ } | undefined, olds: unknown, rolePath: (mount: string, name: string) => string) => string | undefined;
68
+ /**
69
+ * The diff's first question, asked before `isResolved(news)`: the move from the identity this
70
+ * resource last wrote or tried (`tried`) to the declared one, or undefined when both name the same
71
+ * object or either is still unknown. `pathOf` is the API path an identity is read at.
72
+ *
73
+ * ⛔ A MOVE ONTO AN OBJECT THAT ALREADY EXISTS FAILS THE PLAN. MEASURED 2026-09-21 through the engine
74
+ * (rename-occupied.test.ts): two policies that swapped names under `RemovalPolicy.destroy()` each
75
+ * planned `replace`, each new generation wrote over the other's live policy, and each old
76
+ * generation's delete then removed the name the other had just written. The deploy was green and
77
+ * both policies were gone, every grant revoked. A shift (a → b while b → c), and a move onto the
78
+ * name of a resource leaving the stack, were measured ending the same way. So was reverting a
79
+ * move whose new generation failed: the old generation still holds the name, and its delete runs
80
+ * after the revert has rewritten it. That is why `tried` falls back to the props of an unfinished
81
+ * write.
82
+ * ⚠️ THE DIFF CANNOT SEE THE REMOVAL POLICY, SO THIS REFUSES UNDER `retain` TOO, where a swap, or a
83
+ * move back onto a retained old generation, would have been harmless. The cost is a move in two
84
+ * deploys through a free name, or removing the target by hand. The alternative is a green deploy
85
+ * that deletes what it has just written.
86
+ * ★ ONE PLAN-TIME READ, OF A PATH THE NEW GENERATION'S RECONCILE READS ANYWAY. A 404, a missing mount
87
+ * included, is free; a refused read fails the plan, as any other diff read does.
88
+ */
89
+ export declare const judgeMove: (family: string, tried: string | undefined, declared: string | undefined, pathOf: (identity: string) => string, key?: (identity: string) => string) => Effect.Effect<{
90
+ from: string;
91
+ to: string;
92
+ } | undefined, import("./bao-status.ts").BaoError, import("effect/unstable/http/HttpClient").HttpClient>;
93
+ /**
94
+ * Reconcile's guard: an `update` whose prior attributes name another object.
95
+ *
96
+ * ★ REACHED ONLY WHEN THE DIFF COULD NOT SEE THE NEW IDENTITY at plan time. A fresh create, and the
97
+ * new generation of a `replace`, both reach reconcile with `output` undefined (Apply.ts passes
98
+ * `output: attr`, which is empty for a new generation). An `update` reaches it with the previous
99
+ * generation's attributes.
100
+ * ★ THE FAILED DEPLOY SETTLES ITSELF. Apply has already committed the row as `updating`, with the new
101
+ * props and the OLD attributes. By the next plan the upstream Output has landed, so the diff sees
102
+ * both names and answers `replace` (or refuses, if the new name is already taken: `judgeMove`).
103
+ * The old object is then deleted, or retained, by the removal policy, with no hand repair.
104
+ */
105
+ export declare const refuseMovedUpdate: (family: string, from: string, to: string) => Effect.Effect<never, never, never>;
106
+ //# sourceMappingURL=rename.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rename.d.ts","sourceRoot":"","sources":["../../src/openbao/rename.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,OAAO,KAAK,MAAM,MAAM,eAAe,CAAC;AAKxC;;;;;;;;GAQG;AACH,eAAO,MAAM,cAAc,SAAU,OAAO,OAAO,MAAM,KAAG,MAAM,GAAG,SAIpE,CAAC;AAEF,wGAAwG;AACxG,eAAO,MAAM,aAAa,SAAU,OAAO,OAAO,MAAM,KAAG,OAI1D,CAAC;AAEF,yFAAyF;AACzF,eAAO,MAAM,gBAAgB,SACrB,OAAO,YACH,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,MAAM,KAChD,MAAM,GAAG,SAIX,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,SAAS,SAAU,MAAM,KAAG,MAAmC,CAAC;AAE7E;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,OAAO,WACV,MAAM,YACJ,MAAM,GAAG,SAAS,QACvB,CAAC,QAAQ,EAAE,MAAM,KAAK,MAAM,KAChC,OAAO,GAAG,SAAiF,CAAC;AAE/F;;;GAGG;AACH,eAAO,MAAM,aAAa,WAChB;IAAE,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;CAAE,GAAG,SAAS,QAC/D,OAAO,YACH,CAAC,KAAK,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,KAAK,MAAM,KAChD,MAAM,GAAG,SACmF,CAAC;AAEhG;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,eAAO,MAAM,SAAS,WACZ,MAAM,SACP,MAAM,GAAG,SAAS,YACf,MAAM,GAAG,SAAS,UACpB,CAAC,QAAQ,EAAE,MAAM,KAAK,MAAM,QAC/B,CAAC,QAAQ,EAAE,MAAM,KAAK,MAAM;;;wGAgB/B,CAAC;AAEL;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,iBAAiB,WAAY,MAAM,QAAQ,MAAM,MAAM,MAAM,uCAOvE,CAAC"}
package/docs/caddy.md ADDED
@@ -0,0 +1,180 @@
1
+ # Caddy — `@homeflare/alchemy/caddy`
2
+
3
+ Declare a running Caddy's config as Caddyfile text and apply it through Caddy's own admin API:
4
+ `POST /load` to apply (a graceful reload), `GET /config/` to detect drift. `caddyWithFile()` also
5
+ writes the same Caddyfile to the file Caddy starts from, with launchd's `HostFile`, so a restart
6
+ keeps it. Every admin call goes through one injectable `CaddyAdmin` transport.
7
+
8
+ ```ts
9
+ import * as Effect from 'effect/Effect';
10
+ import * as Layer from 'effect/Layer';
11
+ import { caddyProviders, caddyWithFile } from '@homeflare/alchemy/caddy';
12
+ import { LaunchdJob, launchdProviders } from '@homeflare/alchemy/launchd';
13
+
14
+ const caddyfile = `app.example.com {
15
+ \treverse_proxy 127.0.0.1:8080
16
+ \ttls {
17
+ \t\tdns cloudflare {env.CF_API_TOKEN}
18
+ \t}
19
+ }
20
+ `;
21
+
22
+ export const edge = Effect.gen(function* () {
23
+ const { file } = yield* caddyWithFile('caddy', { caddyfile, path: '/usr/local/etc/Caddyfile' });
24
+ yield* LaunchdJob('caddy-daemon', {
25
+ label: 'com.example.caddy',
26
+ domain: 'system',
27
+ // --envfile: a file a secret renderer (openbao-agent) writes; {env.CF_API_TOKEN} reads it.
28
+ programArguments: [
29
+ '/usr/local/bin/caddy',
30
+ 'run',
31
+ '--config',
32
+ file.path,
33
+ '--adapter',
34
+ 'caddyfile',
35
+ '--envfile',
36
+ '/usr/local/etc/caddy.env',
37
+ ],
38
+ runAtLoad: true,
39
+ keepAlive: true,
40
+ });
41
+ });
42
+ // ★ launchdProviders() for the HostFile and the job; caddyProviders() for the config.
43
+ export const providers = Layer.mergeAll(caddyProviders(), launchdProviders());
44
+ ```
45
+
46
+ ## CaddyConfig
47
+
48
+ | prop | notes |
49
+ | ------------ | ---------------------------------------------------------------------------- |
50
+ | `caddyfile` | the whole Caddyfile; it REPLACES the running config. ⛔ never a secret |
51
+ | `sourceFile` | the file Caddy was started with; `caddyWithFile()` sets it from its HostFile |
52
+
53
+ | attribute | what |
54
+ | -------------- | ------------------------------------------------------------------------------------ |
55
+ | `configSha256` | SHA-256 of the adapted JSON Caddy reported after the apply — never the config itself |
56
+ | `endpoint` | where the admin API was reached |
57
+ | `sourceFile` | as applied |
58
+
59
+ | step | what happens |
60
+ | ------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
61
+ | plan | `POST /adapt` the Caddyfile (side-effect free): a syntax error, a literal secret, no apps or an unsafe `admin` block fails the **plan** |
62
+ | apply | `POST /load` with `text/caddyfile`, then `GET /config/` must hash to what `/adapt` produced; skipped when Caddy already runs it |
63
+ | refused | Caddy keeps the old config (it rolls back itself); the deploy fails with Caddy's reason and says whether the old config is still running |
64
+ | read | the digest of `GET /config/` — with no state, a running Caddy is **adopted** (its live config becomes the baseline) |
65
+ | drift | live ≠ stored → `update`: a hand `curl` to the API, or a restart that loaded a different file |
66
+ | replace | never — a new Caddyfile is a reload of the same Caddy |
67
+ | delete | ⛔ nothing. See [Removing it](#removing-it) |
68
+
69
+ The digests compare adapted JSON, not bytes: `/adapt` answers Go struct order, `GET /config/` sorted
70
+ keys with `<>&` escaped and a trailing newline. `digest.ts` erases all three. ★ Measured 2026-09-21
71
+ against a throwaway Caddy 2.11.4: the digests agree after a load and for a Caddy started from the
72
+ same file, and a refused load kept serving the old config and answered **200** with the error after
73
+ the adapter's warnings, which the provider still reports as a refusal.
74
+
75
+ ⚠️ **A stopped Caddy does not fail the plan.** Its launchd job may be the fix in the same stack, so
76
+ `read` and `diff` plan a create/update with a warning (the plan-time `/adapt` did not run). The apply
77
+ still needs Caddy, and fails loudly until it answers.
78
+
79
+ ## Order: file, then `/load`
80
+
81
+ `caddyWithFile()` passes the HostFile's `path` Output into the CaddyConfig, so Alchemy writes the
82
+ file first. It is the only order that bootstraps: a new host's Caddy job starts from the file,
83
+ before any admin API exists to load into. What bounds the cost:
84
+
85
+ - ★ **A Caddyfile that does not adapt never reaches disk** once the config has state: the plan-time
86
+ `/adapt` fails the plan. On the very first deploy the path is still an Output, Alchemy skips the
87
+ adoption probe, and the check runs at apply instead.
88
+ - ⚠️ **A Caddyfile that adapts but that Caddy refuses** (a port in use, a missing cert file) is on
89
+ disk when `/load` fails. Caddy keeps serving the old config — but a **restart before the fix loads
90
+ the refused file**. Fix and redeploy, or run Caddy with `--resume` (below).
91
+ - ⛔ Secrets are refused at declaration, before the HostFile exists (its `content` is a prop too).
92
+ - ★ Both resources retain: removing them from a stack leaves the file a restart needs.
93
+
94
+ ## Autosave and `--resume`
95
+
96
+ After every successful load (ours included) Caddy writes the config JSON to `autosave.json`
97
+ (`$XDG_CONFIG_HOME/caddy/`, else `~/Library/Application Support/Caddy/` on macOS, `~/.config/caddy/`
98
+ on Linux), unless the Caddyfile says `persist_config off`. ⚠️ A daemon started with no `HOME` and no
99
+ `XDG_CONFIG_HOME` falls back to `./caddy/` under its working directory (storage.go AppConfigDir):
100
+ set `XDG_CONFIG_HOME` in the launchd job so autosave lands where you expect.
101
+
102
+ - **Without `--resume`** (the mini's Caddy today): a restart loads `--config`. The file and the
103
+ running config agree after every successful deploy; see the ⚠️ above for a failed one.
104
+ - **With `caddy run --resume --config <file>`**: a restart loads `autosave.json` — the last config
105
+ Caddy **accepted** — and `--config` only when no autosave exists. A refused file on disk can then
106
+ never take sites down. The trade: a hand change through the API also survives a restart; the next
107
+ plan reports it as drift and the deploy puts the declaration back.
108
+ - `--watch` reloads the file on change; our `/load` right after is then a no-op ("config is
109
+ unchanged").
110
+ - `sourceFile` rides as `Caddy-Config-Source-File`, as `caddy reload` sends it. Without it a load
111
+ makes Caddy forget its source file, and SIGUSR1 stops reloading from it. The header only KEEPS the
112
+ file Caddy started with (caddy.go ClearLastConfigIfDifferent); it never sets a new one. ⚠️ Under
113
+ `--resume` with an autosave, Caddy records no source file at all, so SIGUSR1 has nothing to reload.
114
+
115
+ ## Secrets
116
+
117
+ Alchemy stores props unencrypted, and the Caddyfile is a prop. Use RUNTIME placeholders, which Caddy
118
+ resolves in its own process, so the value never enters the state, the adapted JSON or the autosave:
119
+
120
+ - `{env.NAME}` — start Caddy with `--envfile <path>`; a secret renderer (openbao-agent) writes that
121
+ file. A rotated value needs a Caddy restart.
122
+ - `{file./path}` — the file's contents (trailing newline trimmed), read when the placeholder runs.
123
+ - ⚠️ `{$NAME}` is substituted at **adapt** time: the value is in the adapted JSON, readable at
124
+ `GET /config/` and written to `autosave.json`. Not refused, but worse.
125
+ - ⛔ `{$NAME:default}` puts the default in the prop, and Caddy uses it whenever `NAME` is unset. The
126
+ tripwire checks the default as a literal.
127
+ - `basic_auth` replaces placeholders in the hash too: `admin {env.ADMIN_HASH}`.
128
+
129
+ The tripwire refuses a PEM private key, a literal after `dns`/`acme_dns <provider>`, a secret-named
130
+ subdirective (`api_token`, `client_secret`, `password`, …) with a literal value, a literal
131
+ `Authorization`/`Cookie`/`X-Api-Key` header, and token shapes (GitHub, AWS, Slack, `sk-…`, JWT,
132
+ bcrypt/argon2 hashes). It never echoes what it found. It is a tripwire, not a scanner.
133
+
134
+ ## The admin endpoint
135
+
136
+ ⛔ The admin API has no authentication. `localCaddyAdmin()` accepts only `http://` loopback
137
+ (`127.0.0.0/8`, `localhost`, `[::1]`) or `unix:///path`; the default is `http://127.0.0.1:2019`.
138
+
139
+ - **Host and Origin.** Caddy refuses a request whose `Host` is not an allowed origin (DNS-rebinding
140
+ guard; measured on Caddy 2.11.4: `403 host not allowed`), and checks `Origin` when one is sent or
141
+ `enforce_origin` is on. The transport sends both as the Caddy CLI does. A unix socket skips the
142
+ Host check entirely.
143
+ - **`admin { origins … }`** narrowed, or **an SSH-forwarded port** (Caddy checks the Host against
144
+ ITS port): pass `hostHeader: 'localhost:2019'`.
145
+ - **Another host's Caddy** (SSH later): forward its socket or port to loopback here and point a
146
+ `localCaddyAdmin()` at it, or implement `CaddyAdmin` over your own route. Never expose :2019.
147
+ - **A Caddyfile cannot strand the provider.** Before loading, the adapted `admin` block is refused
148
+ if it turns the API off, listens off loopback or somewhere the transport does not reach (another
149
+ port, socket or loopback address: `[::1]` is not `127.0.0.1`, and `localhost` binds IPv4), allows
150
+ no Host the transport sends (its `origins`, or Caddy's loopback defaults when there are none), sets
151
+ `enforce_origin` over a unix socket (no Origin is sent there), enables `remote`, or pulls config.
152
+ - ⛔ **No `admin` address means Caddy's default** (`localhost:2019`, or `$CADDY_ADMIN`) after the
153
+ load. That is refused unless the transport is at that default: a Caddy reached on a socket or
154
+ another port declares `admin <address>` in its Caddyfile.
155
+
156
+ ## Adoption
157
+
158
+ With no state, `read` reports the running Caddy's live config as plain (owned) attributes, so the
159
+ first deploy adopts it silently and its forced update loads the declared Caddyfile — a full replace
160
+ of whatever ran before. ⚠️ Review the plan: the Caddy on this loopback admin API is the one you get.
161
+
162
+ ## Removing it
163
+
164
+ ⛔ Delete never touches Caddy, under either removal policy. There is no "less config" to fall back
165
+ to, and an empty config stops every server. `retain` is the default; with `destroy`, delete still
166
+ only forgets. It never calls `/stop` or `DELETE /config/`. An empty Caddyfile, or one that adapts
167
+ to no apps (only comments or global options), is refused for the same reason. To empty a Caddy, do
168
+ it by hand at its admin API.
169
+
170
+ ## Traps
171
+
172
+ - ⚠️ `import` paths resolve against Caddy's working directory (the API adapts with no file name).
173
+ Keep them absolute.
174
+ - ⚠️ `/adapt` runs on the **running** binary. A Caddyfile that uses a plugin's directive (Cloudflare
175
+ DNS, caddy-security, layer4) needs that custom build running before it will validate.
176
+ - ⚠️ **Bootstrap race.** Where Caddy is not running yet, the config can reach the admin API before
177
+ its launchd job has Caddy listening. The transport retries a refused connection (`retries`,
178
+ `retryDelayMs`; default 2 × 500 ms) — raise them, or rerun the deploy.
179
+ - ⚠️ One `CaddyAdmin` per stack: the transport picks the Caddy, not a prop. Pointing it at another
180
+ Caddy plans an update that loads there; the old one keeps what it had.
@@ -0,0 +1,188 @@
1
+ # MeshNode — a Cloudflare Mesh node without its token in state
2
+
3
+ `MeshNode` (`Cloudflare.MeshNode`) declares a Cloudflare Mesh node: the `warp_connector` object
4
+ that a Linux host joins with the Cloudflare One Client. `fetchMeshNodeToken` fetches the node's
5
+ enrolment token on demand, for a one-off enrolment step. Read 2026-09-21 against
6
+ `alchemy@2.0.0-beta.79`, `@distilled.cloud/cloudflare@1.0.0-rc.12` and Cloudflare's Mesh docs.
7
+
8
+ ## Why not `Cloudflare.Tunnel.WarpConnector`
9
+
10
+ - ⛔ **It puts the node token in state.** Its `toAttributes` calls the token endpoint on every
11
+ `read`, `reconcile` and `list`, and stores the result as an attribute. `Redacted` keeps it out
12
+ of logs, not out of the state store. The token lets any Linux host join the account's Mesh as
13
+ that node.
14
+ - **It has no `ha` prop.** High availability can only be chosen when the node is created.
15
+ - **Its plans need a Write token.** The token endpoint requires "Cloudflare One Connectors Write"
16
+ (or the cloudflared or Tunnel Write groups). `MeshNode` never calls it, so a plan can run on a
17
+ read token.
18
+
19
+ ## Declaring a node
20
+
21
+ ```ts
22
+ import * as Cloudflare from 'alchemy/Cloudflare';
23
+ import { MeshNode, providers as homeflareCloudflare } from '@homeflare/alchemy/cloudflare';
24
+
25
+ // providers: Layer.mergeAll(Cloudflare.providers(), homeflareCloudflare()),
26
+ const door = yield * MeshNode('vault-door', { name: 'door-a', ha: false });
27
+ ```
28
+
29
+ | prop | type | notes |
30
+ | ------ | --------- | --------------------------------------------------------------------------------- |
31
+ | `name` | `string` | Unique per account. The identity used for adoption. Renames in place. |
32
+ | `ha` | `boolean` | **Required, create-only.** The API default is `false`, the dashboard's is `true`. |
33
+
34
+ Attributes: `id` (the node UUID), `accountId`, `name`, `status` (`inactive`, `degraded`,
35
+ `healthy` or `down`, as of the last read or write) and `ha`. ⛔ There is no token attribute.
36
+
37
+ The account and credentials come from Alchemy's own Cloudflare environment
38
+ (`Cloudflare.CloudflareApiLive()`): an Alchemy profile, or `CLOUDFLARE_API_TOKEN` and
39
+ `CLOUDFLARE_ACCOUNT_ID` in CI. So the node lands in the same account as the `Gateway.Rule` and
40
+ `DNS.Record` resources that refer to it. `providers()` builds that layer itself, so the provider
41
+ works whether or not the stack also merges `Cloudflare.providers()`.
42
+
43
+ ⛔ **Removal policy: `retain` by default**, like the kit's other resources whose deletion breaks
44
+ their consumers. Dropping the declaration or `alchemy destroy` leaves the node live and only drops
45
+ it from state. Opt a declaration into deletion with `.pipe(RemovalPolicy.destroy())`.
46
+
47
+ ### Choosing `ha`: a door is `ha: false`
48
+
49
+ HA fails over the CIDR **routes** a node advertises. A node with no routes (a door: callers dial
50
+ its Mesh IP) gains nothing from it, because the Mesh IP belongs to each replica: "Nodes without
51
+ routes do not benefit from HA failover" (Cloudflare's Mesh HA page, read 2026-09-21). After a
52
+ failover the promoted replica answers on its own Mesh IP, so nothing dialling the first one
53
+ follows it.
54
+
55
+ So a door is `ha: false`, and **a second door is a second `MeshNode`** with its own name, token
56
+ and Mesh IP. Callers list both addresses. Keep `ha: true` for a subnet gateway with routes, on a
57
+ device profile that uses MASQUE.
58
+
59
+ ## What each change does
60
+
61
+ | change | answer | what happens on Cloudflare |
62
+ | ------------------------ | --------------------------- | ------------------------------------------------------------------------------------------- |
63
+ | `name` | `update` | `PATCH`. The id, token, enrolled replicas and Mesh IPs all survive. |
64
+ | `ha`, same `name` | `replace`, **delete-first** | `retain` (default): **refused**, nothing written. `destroy`: old deleted, then new created. |
65
+ | `ha` and `name` together | `replace`, create-first | The new node is created. Under `retain` the old one stays live. |
66
+ | account (provider env) | `replace`, create-first | As above; names are unique per account, so the two cannot collide. |
67
+
68
+ ⛔ **An `ha` replace is an outage for that node.** The new node has a new id and a new token, and
69
+ each replica gets a new Mesh IP. Re-enrol every replica with the new token, and update anything
70
+ that pins the old Mesh IP (Gateway rules, listener allow-lists) in the same change.
71
+
72
+ ★ **Why delete-first only when the name stays.** The new node needs the name the old one holds, so
73
+ creating first would fail with code 1013 (`DuplicateTunnelName`). ⚠️ Unmeasured: whether a
74
+ just-deleted node frees its name at once. If it does not, the create fails with code 1013 and a
75
+ sentence; deploy again once the name is free. An invalid declaration is refused at plan time,
76
+ before anything is deleted.
77
+
78
+ ### An `ha` change under `retain`
79
+
80
+ `retain` also skips the old node's delete inside a replace. So a same-name `ha` change keeps the old
81
+ node, which still holds the name, and the create **refuses** with a sentence and writes nothing. It
82
+ never quietly reuses the old node, because that node still has the old `ha`. Measured through
83
+ Alchemy's own plan/apply in `mesh-node-policy.test.ts`:
84
+
85
+ | you want | do |
86
+ | -------------------- | --------------------------------------------------------------------------------------------------------------------- |
87
+ | the replace | Deploy once with `.pipe(RemovalPolicy.destroy())`: delete, then create. Then drop it. |
88
+ | the replace, by hand | Delete the old node yourself, then deploy again. |
89
+ | to keep the old node | `alchemy state rm <stack>/<stage>/<fqn>` (state only, not the node), then deploy the old declaration + `adopt(true)`. |
90
+
91
+ ⚠️ **Reverting `ha` is not enough.** The refused deploy leaves a `replacing` row, and the next
92
+ plan resumes that replace whatever the props say, so it refuses again.
93
+
94
+ ⚠️ **`adopt(true)` alone does nothing to a `replacing` row.** The plan asks `read` to adopt only
95
+ when a resource has no row, or after an interrupted first create. So every way back from inside a
96
+ replace starts with `alchemy state rm`.
97
+
98
+ ⛔ **The old-node advice holds only when the name stayed.** After an `ha` + `name` change (a
99
+ create-first replace), a node already holding the new name is another node, not the old one.
100
+ `RemovalPolicy.destroy()` never touches it, and the refusal says not to delete it: choose another
101
+ name, or check its HA badge, drop the row and adopt it.
102
+
103
+ ⚠️ Never deploy with `DISTILLED_DEBUG_HTTP` set: distilled prints the start of every response,
104
+ and the create response carries the node's `token` field (Cloudflare's HA page says so).
105
+
106
+ ⚠️ Cloudflare may refuse to delete a node while replicas are connected. This is noted in Alchemy's
107
+ WarpConnector and was not measured here. Stop the client on every replica before a destroy or an
108
+ `ha` replace.
109
+
110
+ ## Adoption
111
+
112
+ An existing node is found by **exact** name and returned `Unowned`. Alchemy refuses to take it
113
+ over until the resource is wrapped in `adopt(true)` (or `--adopt` is passed). A deleted node is
114
+ never matched, and a name that matches two live nodes is refused.
115
+
116
+ An interrupted create (the node was made, its state was not saved) is found the same way on the
117
+ next plan, so it too needs `adopt(true)`. ⚠️ distilled retries a create after a lost response or a
118
+ 5xx, and the retry is answered code 1013 by the node the first attempt made: the error then names
119
+ that node's id. It is never adopted silently, because a concurrent creator looks the same. Inside
120
+ a replace, drop the row before `adopt(true)`, as above.
121
+
122
+ ⚠️ **Adoption records the declared `ha`.** No documented read returns a node's HA flag:
123
+ `GET /warp_connector/{id}` has no such field. Check the node's **HA** badge in the dashboard
124
+ before adopting. A wrong value cannot be detected by any later plan.
125
+
126
+ ## Enrolling a host: `fetchMeshNodeToken`
127
+
128
+ The token is a runtime credential. It never goes into Alchemy state or the vault; it goes straight
129
+ to the host that runs the node. A one-off Bun `mesh-enroll` step:
130
+
131
+ ```ts
132
+ import { fromApiToken } from '@distilled.cloud/cloudflare/Credentials';
133
+ import { fetchMeshNodeToken } from '@homeflare/alchemy/cloudflare';
134
+ import * as Effect from 'effect/Effect';
135
+ import * as Layer from 'effect/Layer';
136
+ import * as Redacted from 'effect/Redacted';
137
+ import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
138
+ import { open } from 'node:fs/promises';
139
+
140
+ const token = await Effect.runPromise(
141
+ fetchMeshNodeToken({ accountId, id: nodeId }).pipe(
142
+ Effect.provide(Layer.mergeAll(fromApiToken({ apiToken }), FetchHttpClient.layer)),
143
+ ),
144
+ );
145
+ // ⛔ Created 0600 from the start, as root: no window where a chmod has not happened yet.
146
+ const file = await open('/root/mesh-node.token', 'wx', 0o600);
147
+ await file.writeFile(Redacted.value(token));
148
+ await file.close();
149
+ ```
150
+
151
+ - `{ accountId, name }` works too; the name is matched exactly.
152
+ - The value comes back `Redacted`: `console.log`, `Effect.log` and `JSON.stringify` show
153
+ `<redacted>`. Call `Redacted.value` only at the write.
154
+ - ⛔ **`fromApiToken`, not `Credentials.fromEnv()`.** `fromEnv` falls back to the Global API Key
155
+ (`CLOUDFLARE_API_KEY` + `CLOUDFLARE_EMAIL`) when no token is set.
156
+ - ⚠️ **The endpoint needs a Write permission.** Mint a short-lived token for this step alone. A
157
+ read token gets a 403, and the error names the permission.
158
+ - ⚠️ `warp-cli --accept-tos connector new <token>` (Mesh get-started page) takes the token as an
159
+ **argument**, so it is visible in the host's process list while that command runs. Run it as
160
+ root on the node itself, in a shell that does not keep history.
161
+ - An empty token fails closed. An empty file would otherwise "enrol" a host that never joined.
162
+ - ⛔ **Refused before any request:** `DISTILLED_DEBUG_HTTP` set (distilled then prints the first
163
+ 400 characters of every response body to stderr, which is the whole token), the Global API Key
164
+ (`apiKey` credentials), and an empty API token (a denied secrets grant, not a permission).
165
+
166
+ ## Permissions
167
+
168
+ | step | needs (any one, per the API reference) |
169
+ | ----------------------- | ------------------------------------------------------------------------ |
170
+ | plan / read (get, list) | Cloudflare One Connectors Read, or Cloudflare One Connector: WARP Read |
171
+ | create, rename, delete | Cloudflare One Connectors Write, or Cloudflare One Connector: WARP Write |
172
+ | `fetchMeshNodeToken` | Cloudflare One Connectors Write (see below) |
173
+
174
+ The token endpoint's API reference lists Connectors Write, "Cloudflare One Connector: cloudflared
175
+ Write" and "Cloudflare Tunnel Write"; the Mesh get-started guide lists Connectors Write and
176
+ "Cloudflare One Connector: WARP Write". Connectors Write is the one both name.
177
+
178
+ ## Not covered here
179
+
180
+ - **Routes, device profiles and Gateway rules:** use Alchemy's `Tunnel.Route`,
181
+ `Devices.CustomProfile` and `Gateway.Rule`. ⚠️ HA needs MASQUE on the node's device profile.
182
+ - **The HA provider configuration** (`/warp_connector/{id}/configurations`: `ha_mode` of `none`,
183
+ `disabled`, `aws` or `local` with VIPs) and **manual failover**. Its semantics for a node
184
+ created without `ha` are undocumented, so this resource neither reads nor writes it.
185
+ - **Mesh IPs.** They belong to each replica (the `/connections` endpoint), not to the node, so
186
+ they are not attributes. Pin them in site config and check them against the API.
187
+ - **`alchemy unsafe nuke`.** `list` is empty and nuke skips this type, because Alchemy's own
188
+ WarpConnector provider already lists every `warp_connector` in the account.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@homeflare/alchemy",
3
- "version": "0.6.0",
4
- "description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo, Talos and launchd.",
3
+ "version": "0.7.0",
4
+ "description": "Custom Alchemy providers for Cloudflare, Proxmox, OpenBao, Forgejo, Talos, launchd and Caddy.",
5
5
  "homepage": "https://github.com/taslabs-net/homeflare-kit/tree/main/packages/alchemy#readme",
6
6
  "bugs": "https://github.com/taslabs-net/homeflare-kit/issues",
7
7
  "license": "MIT",
@@ -49,12 +49,17 @@
49
49
  "types": "./dist/launchd/index.d.ts",
50
50
  "default": "./dist/launchd/index.js"
51
51
  },
52
+ "./caddy": {
53
+ "types": "./dist/caddy/index.d.ts",
54
+ "default": "./dist/caddy/index.js"
55
+ },
52
56
  "./package.json": "./package.json"
53
57
  },
54
58
  "publishConfig": {
55
59
  "access": "public"
56
60
  },
57
61
  "peerDependencies": {
62
+ "@distilled.cloud/cloudflare": "1.0.0-rc.12",
58
63
  "@effect/platform-node": "4.0.0-rc.115",
59
64
  "alchemy": "2.0.0-beta.79",
60
65
  "cloudflare": ">=4.5.0",