@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
@@ -0,0 +1,125 @@
1
+ /**
2
+ * `fetchMeshNodeToken` — a Mesh node's enrolment token, fetched on demand for a one-off Bun
3
+ * `mesh-enroll` step. ⛔ Not a Resource, and never an attribute: see mesh-node-form.ts for why
4
+ * the token must stay out of Alchemy state.
5
+ *
6
+ * ⛔ WHERE THE VALUE MAY GO. Straight into a root-owned `0600` file on the node's host, which the
7
+ * enrolment reads (`warp-cli --accept-tos connector new <token>`, per the Mesh get-started page,
8
+ * read 2026-09-21). Nowhere else: not Alchemy state, not a secrets store (a runtime credential
9
+ * belongs to the host that runs it), not a log line, not a CI variable. It is returned
10
+ * `Redacted`, so an accidental `console.log` or `Effect.log` prints `<redacted>`; call
11
+ * `Redacted.value` only at the write.
12
+ * ⚠️ `warp-cli` takes the token as an ARGUMENT, so it is visible in the host's process list for
13
+ * the life of that command. Run the enrolment as root on the node itself, never over a shell
14
+ * whose history is kept.
15
+ * ⚠️ THE ENDPOINT NEEDS A WRITE PERMISSION. Its API reference (read 2026-09-21) accepts
16
+ * "Cloudflare One Connectors Write", "Cloudflare One Connector: cloudflared Write" or
17
+ * "Cloudflare Tunnel Write"; the Mesh get-started guide also names "Cloudflare One Connector:
18
+ * WARP Write". Connectors Write is in both lists. Mint a short-lived token for this step alone;
19
+ * a read-only plan token gets a 403 here.
20
+ *
21
+ * ★ Requires distilled `Credentials` and an `HttpClient`, not Alchemy's CloudflareEnvironment: a
22
+ * Bun script has no Alchemy profile, so the account id is an argument. Provide
23
+ * `Credentials.fromApiToken({ apiToken })` (from `@distilled.cloud/cloudflare/Credentials`) and
24
+ * `FetchHttpClient.layer`. ⛔ Not `Credentials.fromEnv()`: it falls back to the Global API Key
25
+ * (`CLOUDFLARE_API_KEY` + `CLOUDFLARE_EMAIL`) when no token is set — and `refuseUnsafe` below
26
+ * refuses that key rather than trusting the caller to have read this.
27
+ */
28
+ import { Credentials } from '@distilled.cloud/cloudflare/Credentials';
29
+ import * as zeroTrust from '@distilled.cloud/cloudflare/zero-trust';
30
+ import * as Effect from 'effect/Effect';
31
+ import * as Redacted from 'effect/Redacted';
32
+ import { MeshNodeError, findNodeByName } from './mesh-node-api.ts';
33
+
34
+ /** Which node: by id (the `MeshNode` attribute) or by its exact name. */
35
+ export type MeshNodeRef = { readonly accountId: string } & (
36
+ | { readonly id: string; readonly name?: undefined }
37
+ | { readonly name: string; readonly id?: undefined }
38
+ );
39
+
40
+ /**
41
+ * ⛔ REFUSED BEFORE ANY REQUEST IS SENT:
42
+ * - `DISTILLED_DEBUG_HTTP` set. distilled's Cloudflare protocol then `console.error`s the first
43
+ * 400 characters of EVERY response body (protocol.ts, measured in 1.0.0-rc.12) — the token
44
+ * response is `{"success":true,…,"result":"<token>"}`, so the whole token lands on stderr and in
45
+ * whatever captures it. The check reads `process.env` because that is what distilled reads.
46
+ * - The Global API Key (`apiKey` credentials). Account-wide and long-lived: the one credential a
47
+ * one-off enrolment must never use.
48
+ * - An EMPTY API token. A denied secrets-store grant renders empty, and Cloudflare then answers
49
+ * like a bad permission — which would send the operator to widen a grant that was fine
50
+ * (client.ts says the same for `CLOUDFLARE_API_TOKEN`).
51
+ */
52
+ const refuseUnsafe = Effect.gen(function* () {
53
+ if ((globalThis.process?.env?.['DISTILLED_DEBUG_HTTP'] ?? '') !== '') {
54
+ return yield* Effect.fail(
55
+ new MeshNodeError({
56
+ message:
57
+ 'Refusing to fetch a Mesh node token while DISTILLED_DEBUG_HTTP is set: the SDK would print the token to stderr. Unset it and run again.',
58
+ }),
59
+ );
60
+ }
61
+ const credentials = yield* yield* Credentials;
62
+ if (credentials.type === 'apiKey') {
63
+ return yield* Effect.fail(
64
+ new MeshNodeError({
65
+ message:
66
+ 'Refusing the Global API Key for a Mesh node token. Mint a short-lived API token with "Cloudflare One Connectors Write" and pass it with fromApiToken.',
67
+ }),
68
+ );
69
+ }
70
+ if (credentials.type === 'apiToken' && Redacted.value(credentials.apiToken).length === 0) {
71
+ return yield* Effect.fail(
72
+ new MeshNodeError({
73
+ message:
74
+ 'The Cloudflare API token is EMPTY. That is a missing or denied secrets grant, not a Cloudflare permission: re-mint the token before changing any grant.',
75
+ }),
76
+ );
77
+ }
78
+ });
79
+
80
+ const resolveId = (ref: MeshNodeRef) => {
81
+ if (ref.id !== undefined) return Effect.succeed(ref.id);
82
+ const name = ref.name;
83
+ return Effect.flatMap(findNodeByName(ref.accountId, name), (node) =>
84
+ node === undefined
85
+ ? Effect.fail(new MeshNodeError({ message: `No live Mesh node named "${name}".` }))
86
+ : Effect.succeed(node.id),
87
+ );
88
+ };
89
+
90
+ /**
91
+ * ⛔ An EMPTY token fails closed: `warp-cli connector new ""` would not enrol, and a script that
92
+ * wrote an empty file would report success over a host that never joined.
93
+ * ⛔ The failures made here name the node and account and quote no response body, because the
94
+ * success body IS the token. Other SDK errors pass through; they come only from non-2xx
95
+ * responses, which carry an error envelope, not a token.
96
+ */
97
+ export const fetchMeshNodeToken = (ref: MeshNodeRef) =>
98
+ Effect.gen(function* () {
99
+ yield* refuseUnsafe;
100
+ const nodeId = yield* resolveId(ref);
101
+ const token = yield* zeroTrust
102
+ .getTunnelWarpConnectorToken({ accountId: ref.accountId, tunnelId: nodeId })
103
+ .pipe(
104
+ Effect.catchTag('TunnelNotFound', () =>
105
+ Effect.fail(
106
+ new MeshNodeError({
107
+ message: `Mesh node ${nodeId} does not exist in account ${ref.accountId}.`,
108
+ }),
109
+ ),
110
+ ),
111
+ Effect.catchTag('Forbidden', () =>
112
+ Effect.fail(
113
+ new MeshNodeError({
114
+ message: `Reading Mesh node ${nodeId}'s token was refused (403). The endpoint needs a Write permission such as "Cloudflare One Connectors Write", on a token scoped to account ${ref.accountId}.`,
115
+ }),
116
+ ),
117
+ ),
118
+ );
119
+ if (typeof token !== 'string' || token.length === 0) {
120
+ return yield* Effect.fail(
121
+ new MeshNodeError({ message: `Mesh node ${nodeId} returned an empty token.` }),
122
+ );
123
+ }
124
+ return Redacted.make(token);
125
+ });
@@ -0,0 +1,172 @@
1
+ /**
2
+ * MeshNodeProvider against a fake Cloudflare (fake-mesh.ts), its handlers called the way the
3
+ * engine calls them.
4
+ *
5
+ * ⛔ THE ASSERTION THAT MATTERS MOST: no lifecycle handler ever requests `/token`, and nothing
6
+ * the provider returns contains the node token — even though the fake's create response carries
7
+ * one, as Cloudflare's HA page says the real one does.
8
+ */
9
+ import { describe, expect, test } from 'bun:test';
10
+ import { Unowned } from 'alchemy/AdoptPolicy';
11
+ import type { StackServices } from 'alchemy/Stack';
12
+ import * as Effect from 'effect/Effect';
13
+ import type * as Layer from 'effect/Layer';
14
+ import { FAKE_ACCOUNT, FAKE_API_TOKEN, fakeMesh } from './fake-mesh.ts';
15
+ import { diff, extra, ids, read, reconcile, run, stored, tokenReads } from './mesh-node-harness.ts';
16
+ import { providers } from './providers.ts';
17
+
18
+ // ⛔ Compile-time: a stack's `providers` must be `Layer<…, never, StackServices>`. Adding MeshNode
19
+ // first leaked Alchemy's auth errors into the error channel; `bun run types` now catches that.
20
+ type Built = Layer.Success<ReturnType<typeof providers>>;
21
+ const _stackShaped: Layer.Layer<Built, never, StackServices> = providers();
22
+ void _stackShaped;
23
+
24
+ describe('MeshNodeProvider', () => {
25
+ test('create sends ha, and the attributes carry no token', async () => {
26
+ const fake = fakeMesh();
27
+ const out = await run(fake, (p) => reconcile(p, { name: 'door-a', ha: true }));
28
+ const post = fake.seen.find((s) => s.method === 'POST');
29
+ expect(post?.body).toEqual({ name: 'door-a', ha: true });
30
+ expect(out).toMatchObject({
31
+ name: 'door-a',
32
+ ha: true,
33
+ accountId: FAKE_ACCOUNT,
34
+ status: 'inactive',
35
+ });
36
+ expect(Object.keys(out).sort()).toEqual(['accountId', 'ha', 'id', 'name', 'status']);
37
+ expect(JSON.stringify(out)).not.toContain('fake-node-token');
38
+ expect(tokenReads(fake)).toBe(0);
39
+ expect(new Set(fake.auth)).toEqual(new Set([`Bearer ${FAKE_API_TOKEN}`]));
40
+ });
41
+
42
+ test('read with state refreshes by id; without state it adopts by exact name, Unowned', async () => {
43
+ const fake = fakeMesh();
44
+ fake.seed({ name: 'door-a-old' });
45
+ fake.seed({ name: 'door-a-older' });
46
+ const node = fake.seed({ name: 'door-a', status: 'healthy' });
47
+ const props = { name: 'door-a', ha: false };
48
+ const [owned, probe] = await run(fake, (p) =>
49
+ Effect.gen(function* () {
50
+ const output = stored(FAKE_ACCOUNT, { id: node.id });
51
+ return [yield* read(p, props, output), yield* read(p, props)] as const;
52
+ }),
53
+ );
54
+ expect(Unowned.is(owned)).toBe(false);
55
+ expect(owned).toMatchObject({ id: node.id, status: 'healthy' });
56
+ expect(Unowned.is(probe)).toBe(true);
57
+ expect(probe).toMatchObject({ id: node.id, name: 'door-a', ha: false });
58
+ expect(tokenReads(fake)).toBe(0);
59
+ });
60
+
61
+ test('a prefix-only match or a deleted node is not adopted', async () => {
62
+ const fake = fakeMesh();
63
+ fake.seed({ name: 'door-a-2' });
64
+ fake.seed({ name: 'door-a', deleted_at: '2026-09-20T00:00:00Z' });
65
+ const probe = await run(fake, (p) => read(p, { name: 'door-a', ha: false }));
66
+ expect(probe).toBeUndefined();
67
+ });
68
+
69
+ test('a rename is a PATCH on the same id', async () => {
70
+ const fake = fakeMesh();
71
+ const [first, second] = await run(fake, (p) =>
72
+ Effect.gen(function* () {
73
+ const first = yield* reconcile(p, { name: 'door-a', ha: false });
74
+ return [first, yield* reconcile(p, { name: 'door-b', ha: false }, first)] as const;
75
+ }),
76
+ );
77
+ expect(second.id).toBe(first.id);
78
+ expect(second.name).toBe('door-b');
79
+ expect(fake.seen.filter((s) => s.method === 'POST')).toHaveLength(1);
80
+ expect(fake.seen.find((s) => s.method === 'PATCH')?.body).toEqual({ name: 'door-b' });
81
+ });
82
+
83
+ test('an unchanged node costs no write', async () => {
84
+ const fake = fakeMesh();
85
+ await run(fake, (p) =>
86
+ Effect.flatMap(reconcile(p, { name: 'door-a', ha: false }), (out) =>
87
+ reconcile(p, { name: 'door-a', ha: false }, out),
88
+ ),
89
+ );
90
+ expect(fake.seen.filter((s) => s.method !== 'GET').map((s) => s.method)).toEqual(['POST']);
91
+ });
92
+
93
+ test('reconcile refuses an ha change that reached it as an update', async () => {
94
+ const fake = fakeMesh();
95
+ const failure = await run(fake, (p) =>
96
+ Effect.flip(
97
+ Effect.flatMap(reconcile(p, { name: 'door-a', ha: false }), (out) =>
98
+ reconcile(p, { name: 'door-a', ha: true }, out),
99
+ ),
100
+ ),
101
+ );
102
+ expect(String(failure)).toContain('create-only');
103
+ });
104
+
105
+ test('create never converges on a node it did not create (a retained old generation)', async () => {
106
+ const fake = fakeMesh();
107
+ fake.seed({ name: 'door-a', ha: false });
108
+ const failure = await run(fake, (p) => Effect.flip(reconcile(p, { name: 'door-a', ha: true })));
109
+ expect(String(failure)).toContain('already exists');
110
+ expect(fake.seen.some((s) => s.method === 'POST')).toBe(false);
111
+ });
112
+
113
+ test('a node deleted out of band is created again', async () => {
114
+ const fake = fakeMesh();
115
+ const [first, second] = await run(fake, (p) =>
116
+ Effect.gen(function* () {
117
+ const first = yield* reconcile(p, { name: 'door-a', ha: false });
118
+ const node = fake.nodes.get(first.id);
119
+ if (node !== undefined) node.deleted_at = '2026-09-21T02:00:00Z';
120
+ return [first, yield* reconcile(p, { name: 'door-a', ha: false }, first)] as const;
121
+ }),
122
+ );
123
+ expect(second.id).not.toBe(first.id);
124
+ expect(fake.live()).toHaveLength(1);
125
+ });
126
+
127
+ test('delete removes the node, and deleting it again succeeds', async () => {
128
+ const fake = fakeMesh();
129
+ await run(fake, (p) =>
130
+ Effect.gen(function* () {
131
+ const output = yield* reconcile(p, { name: 'door-a', ha: false });
132
+ const olds = { name: 'door-a', ha: false };
133
+ yield* p.delete({ ...ids, ...extra, olds, output });
134
+ yield* p.delete({ ...ids, ...extra, olds, output });
135
+ }),
136
+ );
137
+ expect(fake.live()).toHaveLength(0);
138
+ expect(fake.seen.filter((s) => s.method === 'DELETE')).toHaveLength(2);
139
+ });
140
+
141
+ test('diff reads the account from the provider environment', async () => {
142
+ const fake = fakeMesh();
143
+ const props = { name: 'door-a', ha: false };
144
+ const plan = (p: Parameters<typeof diff>[0]) => diff(p, props, props, stored(FAKE_ACCOUNT));
145
+ expect(await run(fake, plan)).toBeUndefined();
146
+ expect(await run(fake, plan, '00000000000000000000000000000002')).toEqual({
147
+ action: 'replace',
148
+ });
149
+ });
150
+
151
+ test('an invalid declaration fails the PLAN, before a delete-first replace tears anything down', async () => {
152
+ const fake = fakeMesh();
153
+ const failure = await run(fake, (p) =>
154
+ Effect.flip(
155
+ diff(p, { name: 'door-a ', ha: true }, { name: 'door-a', ha: false }, stored(FAKE_ACCOUNT)),
156
+ ),
157
+ );
158
+ expect(String(failure)).toContain('whitespace');
159
+ expect(fake.seen).toHaveLength(0);
160
+ });
161
+
162
+ test('list is empty and nuke skips it: Alchemy WarpConnector already enumerates these', async () => {
163
+ const fake = fakeMesh();
164
+ fake.seed({ name: 'door-a' });
165
+ const [listed, nuke] = await run(fake, (p) =>
166
+ Effect.map(p.list(), (rows) => [rows, p.nuke] as const),
167
+ );
168
+ expect(listed).toEqual([]);
169
+ expect(nuke).toEqual({ skip: true });
170
+ expect(fake.seen).toHaveLength(0);
171
+ });
172
+ });
@@ -0,0 +1,120 @@
1
+ /**
2
+ * `Cloudflare.MeshNode` — a Cloudflare Mesh node (a `warp_connector` object), declared without
3
+ * its token ever touching Alchemy state. Usage and the enrolment step: docs/mesh-node.md.
4
+ *
5
+ * 🔴 WHY THIS EXISTS ALONGSIDE `Cloudflare.Tunnel.WarpConnector`. A Mesh node that fronts a
6
+ * private service needs a node record in the graph, so the Gateway rules and DNS records that
7
+ * name it deploy together. Alchemy beta.79's WarpConnector would put the node token — the
8
+ * credential that lets any Linux host join the account's Mesh as that node — into plaintext
9
+ * state on every read, and it cannot create an HA node. See mesh-node-form.ts.
10
+ *
11
+ * ★ REMOVAL POLICY: `retain` BY DEFAULT — see the ★ on `MeshNode` below, and what it does to an
12
+ * `ha` replace in mesh-node-lifecycle.ts.
13
+ */
14
+ import { Credentials } from '@distilled.cloud/cloudflare/Credentials';
15
+ import * as Cloudflare from 'alchemy/Cloudflare';
16
+ import { isResolved } from 'alchemy/Diff';
17
+ import * as Provider from 'alchemy/Provider';
18
+ import { Resource, type ResourceClass } from 'alchemy/Resource';
19
+ import * as Context from 'effect/Context';
20
+ import * as Effect from 'effect/Effect';
21
+ import * as HttpClient from 'effect/unstable/http/HttpClient';
22
+ import {
23
+ type MeshNodeAttributes,
24
+ type MeshNodeProps,
25
+ diffMeshNode,
26
+ validateMeshNode,
27
+ } from './mesh-node-form.ts';
28
+ import { deleteMeshNode, readMeshNode, reconcileMeshNode } from './mesh-node-lifecycle.ts';
29
+ import type { Providers } from './providers.ts';
30
+
31
+ export interface MeshNode extends Resource<
32
+ 'Cloudflare.MeshNode',
33
+ MeshNodeProps,
34
+ MeshNodeAttributes,
35
+ never,
36
+ Providers
37
+ > {}
38
+
39
+ /**
40
+ * ★ `defaultRemovalPolicy: 'retain'`, LIKE EVERY KIT RESOURCE WHOSE DELETION BREAKS ITS CONSUMERS
41
+ * (the `Bao.*` families, `R2BucketLock`, the Proxmox storage families; the reasoning is written
42
+ * once in proxmox/resource.ts). Deleting a node cuts every enrolled replica off the Mesh, and
43
+ * nothing brings it back: a new node has a new id, a new token and a new Mesh IP per replica, so
44
+ * each host must be re-enrolled by hand and every Gateway rule or allow-list that pins the old
45
+ * Mesh IP breaks. So dropping the declaration, or `alchemy destroy`, leaves the node live.
46
+ * ⚠️ IT ALSO SKIPS THE OLD NODE'S DELETE IN A REPLACE (Apply.ts, `deleteOldGenerations` and the
47
+ * GC's `retainOldGeneration`, beta.79). A same-name `ha` change therefore REFUSES, deleting
48
+ * nothing, until that deploy opts in with `.pipe(RemovalPolicy.destroy())`; a create-first
49
+ * replace leaves the old node live and unmanaged. Both measured in mesh-node-policy.test.ts.
50
+ * ⛔ `delete` STAYS FULLY IMPLEMENTED. Retain is Terraform's `prevent_destroy`, not a stub: it runs
51
+ * the moment a declaration opts in with `.pipe(RemovalPolicy.destroy())`.
52
+ */
53
+ export const MeshNode: ResourceClass<MeshNode> = Resource<MeshNode>('Cloudflare.MeshNode', {
54
+ defaultRemovalPolicy: 'retain',
55
+ });
56
+
57
+ /** What every call below needs: the account, credentials and HTTP — Alchemy's own trio. */
58
+ export type MeshNodeServices =
59
+ | Cloudflare.CloudflareEnvironment
60
+ | Credentials
61
+ | HttpClient.HttpClient;
62
+
63
+ /** The account id, resolved the way every `alchemy/Cloudflare` provider resolves it. */
64
+ const accountId = Effect.gen(function* () {
65
+ const resolved = yield* yield* Cloudflare.CloudflareEnvironment;
66
+ return resolved.accountId;
67
+ });
68
+
69
+ /**
70
+ * ★ `Provider.effect`, CAPTURING THE THREE SERVICES AT BUILD. `Provider.succeed` (what Alchemy's
71
+ * own providers use) leaves each handler to find CloudflareEnvironment and Credentials in the
72
+ * engine's context at call time — which works for `Cloudflare.providers()` only because it
73
+ * `provideMerge`s them into the stack. Capturing them here keeps this provider correct whether
74
+ * or not the stack also merges `Cloudflare.providers()`.
75
+ */
76
+ export const MeshNodeProvider = () =>
77
+ Provider.effect(
78
+ MeshNode,
79
+ Effect.gen(function* () {
80
+ const services = Context.make(
81
+ Cloudflare.CloudflareEnvironment,
82
+ yield* Cloudflare.CloudflareEnvironment,
83
+ ).pipe(
84
+ Context.add(Credentials, yield* Credentials),
85
+ Context.add(HttpClient.HttpClient, yield* HttpClient.HttpClient),
86
+ );
87
+ const run = <A, E>(effect: Effect.Effect<A, E, MeshNodeServices>) =>
88
+ Effect.provideContext(effect, services);
89
+
90
+ return MeshNode.Provider.of({
91
+ stables: ['id', 'accountId', 'ha'],
92
+ /**
93
+ * ⛔ EMPTY, AND `nuke.skip`. Alchemy's own `Cloudflare.Tunnel.WarpConnector` already lists
94
+ * every `warp_connector` in the account for `alchemy unsafe nuke`; a second listing here
95
+ * would make nuke delete each node twice. `read` does not depend on this.
96
+ */
97
+ list: () => Effect.succeed([]),
98
+ nuke: { skip: true },
99
+ /**
100
+ * ⛔ VALIDATE AT PLAN TIME, NOT ONLY IN `reconcile`. An `ha` replace is delete-first: the
101
+ * engine deletes the old node BEFORE the new generation's reconcile runs, so a
102
+ * declaration refused there would leave no node at all. launchd's job-preflight.ts
103
+ * refuses at plan time for the same reason.
104
+ */
105
+ diff: ({ news, output }) =>
106
+ run(
107
+ Effect.gen(function* () {
108
+ const invalid = isResolved(news) ? validateMeshNode(news) : undefined;
109
+ if (invalid !== undefined) return yield* Effect.fail(invalid);
110
+ return diffMeshNode(news, output, yield* accountId);
111
+ }),
112
+ ),
113
+ read: ({ olds, output }) =>
114
+ run(Effect.flatMap(accountId, (account) => readMeshNode(account, olds, output))),
115
+ reconcile: ({ news, output }) =>
116
+ run(Effect.flatMap(accountId, (account) => reconcileMeshNode(account, news, output))),
117
+ delete: ({ output }) => run(deleteMeshNode(output)),
118
+ });
119
+ }),
120
+ );
@@ -8,16 +8,34 @@
8
8
  * both a `Cloudflare.R2.Bucket` and this lock merges the two:
9
9
  * `Layer.mergeAll(Cloudflare.providers(), HomeflareCloudflare.providers())` — the shape
10
10
  * a consuming stack names for one talking to more than one account.
11
+ *
12
+ * ★ TWO CREDENTIAL PATHS, ONE PER SDK. `R2BucketLock` uses the `cloudflare` SDK through
13
+ * `CloudflareApiLive` (`CLOUDFLARE_API_TOKEN`, fail-closed). `MeshNode` uses Alchemy's own SDK
14
+ * and resolves credentials and the account through `Cloudflare.CloudflareApiLive()` — the same
15
+ * source `Cloudflare.providers()` uses (an Alchemy profile, or `CLOUDFLARE_API_TOKEN` +
16
+ * `CLOUDFLARE_ACCOUNT_ID` in CI), so a node lands in the account its Gateway rules do.
17
+ * ⚠️ That second layer is built again here even when the stack also merges
18
+ * `Cloudflare.providers()`. `Layer.provide`, not `provideMerge`: it feeds MeshNode only and adds
19
+ * nothing to the stack's context.
11
20
  */
21
+ import * as Cloudflare from 'alchemy/Cloudflare';
12
22
  import * as Provider from 'alchemy/Provider';
13
23
  import * as Layer from 'effect/Layer';
14
24
  import { CloudflareApiLive } from './client.ts';
25
+ import { MeshNode, MeshNodeProvider } from './mesh-node.ts';
15
26
  import { R2BucketLock, R2BucketLockProvider } from './r2-bucket-lock.ts';
16
27
 
17
28
  export class Providers extends Provider.ProviderCollection<Providers>()('HomeflareCloudflare') {}
18
29
 
19
30
  export const providers = () =>
20
- Layer.effect(Providers, Provider.collection([R2BucketLock])).pipe(
21
- Layer.provide(R2BucketLockProvider()),
31
+ Layer.effect(Providers, Provider.collection([R2BucketLock, MeshNode])).pipe(
32
+ Layer.provide(
33
+ Layer.mergeAll(
34
+ R2BucketLockProvider(),
35
+ // ⛔ `orDie`, as `Cloudflare.providers()` ends with: a stack's providers layer must not
36
+ // fail, and a profile that cannot resolve is a defect carrying Alchemy's own sentence.
37
+ MeshNodeProvider().pipe(Layer.provide(Cloudflare.CloudflareApiLive()), Layer.orDie),
38
+ ),
39
+ ),
22
40
  Layer.provideMerge(CloudflareApiLive),
23
41
  );
package/src/index.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  * import { TalosCluster } from '@homeflare/alchemy/talos';
11
11
  * import { ProxmoxAcl } from '@homeflare/alchemy/proxmox';
12
12
  * import { LaunchdJob } from '@homeflare/alchemy/launchd';
13
+ * import { CaddyConfig } from '@homeflare/alchemy/caddy';
13
14
  *
14
15
  * ★ WHY THESE EXIST AT ALL. When Alchemy has no resource for something, the alternative
15
16
  * is a runbook step a human runs once — and a plan can never show a missing runbook
@@ -28,7 +28,7 @@ const INLINE_FLAG = /^--?[a-z0-9-]*(?:token|password|passwd|passphrase|secret|ap
28
28
  /** A flag whose NEXT argv element is the secret: `--token abc`. */
29
29
  const BARE_FLAG = /^--?(?:[a-z0-9]+-)*(?:token|password|passwd|passphrase|secret|api-?key)$/i;
30
30
 
31
- const PRIVATE_KEY = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/;
31
+ export const PRIVATE_KEY = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/;
32
32
 
33
33
  /** Paths of every string leaf in a plist value holding a private key. */
34
34
  const keyLeaves = (value: PlistValue, path: string): string[] => {
@@ -1,7 +1,12 @@
1
1
  # Replace semantics: the `Bao.*` providers
2
2
 
3
- Audited 2026-09-21, against `alchemy@2.0.0-beta.79` and OpenBao v2.6.2 source. Every kit
4
- provider must answer `replace` correctly (vault consolidation plan, "Replace freely").
3
+ Audited 2026-09-21, against `alchemy@2.0.0-beta.79` and OpenBao v2.6.2 source, in two batches.
4
+ Every kit provider must answer `replace` correctly (vault consolidation plan, "Replace freely").
5
+
6
+ ★ **Measured, not only read.** `fake-stack.ts` runs beta.79's own `Plan.make` and `apply` over an
7
+ in-memory state store against the fake engines in `fake-engines.ts`. `policy-rename.test.ts`,
8
+ `role-rename.test.ts` and `rename-occupied.test.ts` deploy, rename, and deploy again, then check
9
+ the plan and every call that reached the fake.
5
10
 
6
11
  ## What Alchemy does with each answer
7
12
 
@@ -9,6 +14,12 @@ provider must answer `replace` correctly (vault consolidation plan, "Replace fre
9
14
  (its path) is unchanged.
10
15
  - **`replace`**: create-first by default. The new generation is created, dependents are updated in
11
16
  the same graph, then the old generation is deleted (`Apply.ts`, delete-first comment near :1180).
17
+ ★ That delete gets the **old** generation's attributes (`output: old.attr`), so a provider that
18
+ deletes by `output` deletes the old path. Measured: `PUT` of the new path, then `DELETE` of the
19
+ old one.
20
+ - **No answer** (`undefined`, which every diff returns while `news` holds a pending Output): the
21
+ engine plans `update` if any prop changed (`Plan.ts`, `havePropsChanged`). ⚠️ That is the bug
22
+ class below whenever the identity changed too.
12
23
  - **`deleteFirst: true`**: tears the old one down first. It is only for identities that cannot
13
24
  coexist, such as a unique physical name. No `Bao.*` provider uses it. A rename is a new API
14
25
  path, which never collides with the old one. ⚠️ A mount or auth method whose `type` changes
@@ -27,25 +38,88 @@ state record, so no later plan would mention it. For a mount this meant an **emp
27
38
  beside the retained one that held every secret. For an AppRole it meant the old role's secret_ids
28
39
  kept working for their whole TTL.
29
40
 
30
- ## Touched in this batch
31
-
32
- | Resource | Identity change | Answer now | Other changes |
33
- | ------------------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
34
- | `Bao.Mount` | `path` | ⛔ **fails the plan**, unless `remountFrom` names the old path. Then it is an in-place `update` that moves the mount with `sys/remount`. | `type` → `replace` that **fails at apply** (below). Description and TTLs → `update` (tune). |
35
- | `Bao.AuthMethod` | `path` | Same as `Bao.Mount` (`auth/` prefix). The accessor survives the move. | `type` → `replace` that **fails at apply** (below) |
36
- | `Bao.AuthRole` | `name` | `replace`. Before 2026-09-21 it was `update`, and the old role was orphaned. | `update` |
37
- | `Bao.PkiRole` | `mount`, `name` | `replace`. Before, only a `mount` change was caught; a `name` change orphaned the old role. | `update` (full-replace write) |
38
- | `Bao.JwtRole` | `mount`, `name` | `replace` | `update` (merge write; every managed field is sent) |
39
- | `Bao.KubernetesRole` | `mount`, `name` | `replace` | `update` |
40
- | `Bao.JwtAuthConfig` | `mount` | `replace`. The old delete is a no-op because there is no delete endpoint. | `update` (full-replace write) |
41
- | `Bao.MfaTotpMethod` | `name` | ⛔ **fails the plan**: a new id would strand every enrolled secret | `update` (the upsert keeps the id) |
42
- | `Bao.MfaLoginEnforcement` | `name` | `replace`. Its delete is **refused** (openbao#4030) | `update` |
41
+ 🔴 Reproduced through the engine on 2026-09-21 for `Bao.Policy` under `RemovalPolicy.destroy()`:
42
+ deploy `app-old`, redeclare it as `app-new`, deploy. The plan said `update`, and both policies
43
+ stayed live. `destroy` could not help, because nothing recorded the old one.
44
+
45
+ ## Answers, by resource
46
+
47
+ | Resource | Identity change | Answer now | Other changes |
48
+ | ------------------------- | --------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
49
+ | `Bao.Mount` | `path` | ⛔ **fails the plan**, unless `remountFrom` names the old path. Then it is an in-place `update` that moves the mount with `sys/remount`. | `type` → `replace` that **fails at apply** (below). Description and TTLs → `update` (tune). |
50
+ | `Bao.AuthMethod` | `path` | Same as `Bao.Mount` (`auth/` prefix). The accessor survives the move. | `type` → `replace` that **fails at apply** (below) |
51
+ | `Bao.AuthRole` | `name` | `replace`. Before 2026-09-21 it was `update`, and the old role was orphaned. | `update` |
52
+ | `Bao.PkiRole` | `mount`, `name` | `replace`. Before, only a `mount` change was caught; a `name` change orphaned the old role. | `update` (full-replace write) |
53
+ | `Bao.JwtRole` | `mount`, `name` | `replace` | `update` (merge write; every managed field is sent) |
54
+ | `Bao.KubernetesRole` | `mount`, `name` | `replace` | `update` |
55
+ | `Bao.JwtAuthConfig` | `mount` | `replace`. The old delete is a no-op because there is no delete endpoint. | `update` (full-replace write) |
56
+ | `Bao.MfaTotpMethod` | `name` | ⛔ **fails the plan**: a new id would strand every enrolled secret | `update` (the upsert keeps the id) |
57
+ | `Bao.MfaLoginEnforcement` | `name` | `replace`. Its delete is **refused** (openbao#4030) | `update` |
58
+ | `Bao.Policy` | `name` | `replace` (batch 3A). Compared as OpenBao keys it, trimmed and lowercased, so `Admin` → `admin` is the same policy: `noop`. ⛔ **Fails the plan** onto a name that exists (below). | `update` (fragments digest) |
59
+ | `Bao.CloudflareRole` | `mount`, `name` | `replace` (batch 3A). Exact; a trailing `/` on the mount is the same path. ⛔ **Fails the plan** onto a role that exists (below). | `update` |
60
+ | `Bao.ProxmoxRole` | `mount`, `name` | `replace` (batch 3A). ⛔ **Fails the plan** onto a role that exists, or if the rename also changes `mintUser` unless `allowMintUserChange` names the old one. | `update`. A `mintUser` change at the same path is refused at apply, never replaced. |
43
61
 
44
62
  `hostAppRoles` makes no resources, but its names are identities: renaming a host or a class
45
63
  renames the role. With the logical id taken from the role name, as the README does, the old id
46
64
  leaves the stack: that is an orphan delete, not a `replace`. With a stable logical id,
47
65
  `Bao.AuthRole` plans a `replace`. Under the default `retain`, both leave the old role live.
48
66
 
67
+ ## When the new identity is not known at plan
68
+
69
+ - **Only another prop is pending** (say `fragments` is an Output of an upstream that is also
70
+ changing). `Bao.Policy`, `Bao.CloudflareRole` and `Bao.ProxmoxRole` judge the identity props
71
+ before `isResolved(news)` (`rename.ts`), so the rename still plans `replace`. Measured.
72
+ - **The identity itself is pending.** Then the diff defers: it never answers `replace` on an
73
+ unknown identity, because under `destroy` a replace onto the same path deletes what it just
74
+ wrote. The engine plans `update`, and reconcile, seeing the old attributes name another
75
+ object, **refuses before any write**. The row is left `updating`, with the new props and the
76
+ old attributes. The next deploy sees both names and plans `replace`. Measured, for all three.
77
+ - `Bao.ProxmoxRole` defers the same way while `mintUser` or `allowMintUserChange` is pending on a
78
+ rename, because the re-scope guard cannot be checked. Measured for `allowMintUserChange`.
79
+
80
+ ## A move onto an object that already exists fails the plan
81
+
82
+ ⛔ All three read the new path at plan (`judgeMove`, `rename.ts`) and refuse the move if anything
83
+ is there. 🔴 Measured before the guard: two policies that swapped names under
84
+ `RemovalPolicy.destroy()` both planned `replace`. Each new generation wrote over the other's live
85
+ policy, and each old generation's delete then removed the name the other had just written. The
86
+ deploy was green, and both policies were gone, every grant revoked. Measured the same way:
87
+
88
+ - a shift (`a → b` while `b → c`) deleted `b`, and a move onto the name of a resource leaving the
89
+ stack deleted the name it moved onto;
90
+ - reverting a move whose new generation failed. The old generation still holds the name, and its
91
+ delete runs after the revert rewrites it. So the diff compares against the props of an
92
+ unfinished create or replacement when there are no attributes yet. Declaring the new name again
93
+ finishes the move instead.
94
+
95
+ ⚠️ The diff cannot see the removal policy, so this refuses under `retain` too, where a swap would
96
+ have been harmless. So would a move back onto a retained old generation. Move in two deploys
97
+ through a name nothing holds, or remove the target by hand first. A change of policy-name case, or
98
+ a trailing `/` on a mount, is the same object, so it is not a move and is never refused.
99
+
100
+ ## What `retain` means for these three
101
+
102
+ Under the default `retain` the old generation is kept, and the apply says so ("Replaced resource
103
+ retained."). It is live and unmanaged from then on:
104
+
105
+ - **`Bao.Policy`**: the old policy keeps every grant, for every token, role and group that still
106
+ names it. Under `destroy` they lose those grants at the delete. ⚠️ Only a role that takes the
107
+ policy's `name` Output follows the rename in the same graph. A role that names the policy as a
108
+ literal string (`tokenPolicies: ['deploy']`) keeps the old name, so change it in the same PR.
109
+ - **`Bao.CloudflareRole`**: the old role still mints, for any token whose ACL reaches its
110
+ `creds/<name>`. Under `destroy` every consumer still minting from the old path fails. Tokens
111
+ already minted are leases and live to their own expiry either way.
112
+ - **`Bao.ProxmoxRole`**: the same, and the old role keeps minting under its old `mint_user`. The
113
+ plugin's delete leaves outstanding leases revocable (its own
114
+ `TestDeletingARoleLeavesItsOutstandingLeasesRevocable`).
115
+
116
+ ★ A stack that puts `mount` and `name` in the logical id, as homeflare-openbao's
117
+ `declareCloudflareRoles` does, never reaches the `replace`: the old id leaves the stack as an
118
+ orphan delete, which `retain` also keeps live.
119
+
120
+ ⚠️ A NEW declaration (a new logical id) of a name that already exists still takes that object over
121
+ and rewrites it: these families' `read` never answers `Unowned`, so Alchemy adopts it.
122
+
49
123
  ## Why the moves fail instead of replacing
50
124
 
51
125
  - **Mount and auth path.** A `replace` would create an empty mount. Deleting the old one would
@@ -78,10 +152,12 @@ take every secret with it. (Corrected 2026-09-21: the first audit listed this as
78
152
  3. **Unique names need `deleteFirst`.** Only a mount's `type` change keeps its unique path, and
79
153
  it fails on purpose instead (above).
80
154
 
81
- ## Audited, not touched in this batch (open)
155
+ ## Open
82
156
 
83
- - ⚠️ `Bao.Policy`, `Bao.CloudflareRole` and `Bao.ProxmoxRole` still compare only the live object
84
- at the declared `name` (and `mount`). A rename plans `update` and orphans the old object, which
85
- is the same bug class as above. `Bao.ProxmoxRole` deliberately never answers `replace` for a
86
- `mint_user` change (its own comment), and that choice is sound. The rename case is separate.
87
- - `Bao.SshRole` and `Bao.Plugin` already answer `replace` on an identity change.
157
+ - ⚠️ **Nine families still check the identity after `isResolved(news)`**: `Bao.AuthRole`,
158
+ `Bao.PkiRole`, `Bao.JwtRole`, `Bao.KubernetesRole`, `Bao.JwtAuthConfig`, `Bao.MfaTotpMethod`,
159
+ `Bao.MfaLoginEnforcement`, `Bao.SshRole` and `Bao.Plugin`. A rename that lands in the same
160
+ deploy as any pending Output plans `update`, and none of their reconciles has the moved-update
161
+ guard. For `Bao.MfaTotpMethod` that writes a second method and strands every enrolment. The
162
+ helpers in `rename.ts` close it the same way. (`Bao.Mount` and `Bao.AuthMethod` are safe: their
163
+ reconcile re-plans the move from the stated path.)