@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.
- package/README.md +54 -10
- package/dist/caddy/admin-calls.d.ts +48 -0
- package/dist/caddy/admin-calls.d.ts.map +1 -0
- package/dist/caddy/admin-guard.d.ts +40 -0
- package/dist/caddy/admin-guard.d.ts.map +1 -0
- package/dist/caddy/admin.d.ts +73 -0
- package/dist/caddy/admin.d.ts.map +1 -0
- package/dist/caddy/caddyfile-tripwire.d.ts +5 -0
- package/dist/caddy/caddyfile-tripwire.d.ts.map +1 -0
- package/dist/caddy/config-form.d.ts +27 -0
- package/dist/caddy/config-form.d.ts.map +1 -0
- package/dist/caddy/config-lifecycle.d.ts +45 -0
- package/dist/caddy/config-lifecycle.d.ts.map +1 -0
- package/dist/caddy/config.d.ts +30 -0
- package/dist/caddy/config.d.ts.map +1 -0
- package/dist/caddy/digest.d.ts +7 -0
- package/dist/caddy/digest.d.ts.map +1 -0
- package/dist/caddy/fake-caddy.d.ts +43 -0
- package/dist/caddy/fake-caddy.d.ts.map +1 -0
- package/dist/caddy/index.d.ts +21 -0
- package/dist/caddy/index.d.ts.map +1 -0
- package/dist/caddy/index.js +912 -0
- package/dist/caddy/index.js.map +28 -0
- package/dist/caddy/local-admin.d.ts +45 -0
- package/dist/caddy/local-admin.d.ts.map +1 -0
- package/dist/caddy/providers.d.ts +14 -0
- package/dist/caddy/providers.d.ts.map +1 -0
- package/dist/caddy/with-file.d.ts +47 -0
- package/dist/caddy/with-file.d.ts.map +1 -0
- package/dist/cloudflare/fake-mesh.d.ts +58 -0
- package/dist/cloudflare/fake-mesh.d.ts.map +1 -0
- package/dist/cloudflare/index.d.ts +9 -3
- package/dist/cloudflare/index.d.ts.map +1 -1
- package/dist/cloudflare/index.js +218 -7
- package/dist/cloudflare/index.js.map +9 -4
- package/dist/cloudflare/mesh-node-api.d.ts +75 -0
- package/dist/cloudflare/mesh-node-api.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node-form.d.ts +82 -0
- package/dist/cloudflare/mesh-node-form.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node-harness.d.ts +46 -0
- package/dist/cloudflare/mesh-node-harness.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node-lifecycle.d.ts +22 -0
- package/dist/cloudflare/mesh-node-lifecycle.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node-token.d.ts +23 -0
- package/dist/cloudflare/mesh-node-token.d.ts.map +1 -0
- package/dist/cloudflare/mesh-node.d.ts +48 -0
- package/dist/cloudflare/mesh-node.d.ts.map +1 -0
- package/dist/cloudflare/providers.d.ts +1 -12
- package/dist/cloudflare/providers.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/launchd/index.js.map +2 -2
- package/dist/launchd/secret-tripwire.d.ts +1 -0
- package/dist/launchd/secret-tripwire.d.ts.map +1 -1
- package/dist/openbao/cloudflare-role.d.ts.map +1 -1
- package/dist/openbao/fake-engines.d.ts +30 -0
- package/dist/openbao/fake-engines.d.ts.map +1 -0
- package/dist/openbao/fake-stack.d.ts +29 -0
- package/dist/openbao/fake-stack.d.ts.map +1 -0
- package/dist/openbao/index.js +231 -151
- package/dist/openbao/index.js.map +9 -7
- package/dist/openbao/policy.d.ts +2 -0
- package/dist/openbao/policy.d.ts.map +1 -1
- package/dist/openbao/proxmox-role-form.d.ts +18 -8
- package/dist/openbao/proxmox-role-form.d.ts.map +1 -1
- package/dist/openbao/proxmox-role-wire.d.ts +18 -0
- package/dist/openbao/proxmox-role-wire.d.ts.map +1 -0
- package/dist/openbao/proxmox-role.d.ts.map +1 -1
- package/dist/openbao/rename.d.ts +106 -0
- package/dist/openbao/rename.d.ts.map +1 -0
- package/docs/caddy.md +180 -0
- package/docs/mesh-node.md +188 -0
- package/package.json +7 -2
- package/src/caddy/admin-calls.ts +128 -0
- package/src/caddy/admin-guard.ts +170 -0
- package/src/caddy/admin.ts +74 -0
- package/src/caddy/caddyfile-tripwire.ts +133 -0
- package/src/caddy/config-form.ts +64 -0
- package/src/caddy/config-lifecycle.test.ts +218 -0
- package/src/caddy/config-lifecycle.ts +148 -0
- package/src/caddy/config.ts +116 -0
- package/src/caddy/digest.ts +44 -0
- package/src/caddy/fake-caddy.ts +160 -0
- package/src/caddy/guards.test.ts +156 -0
- package/src/caddy/index.ts +25 -0
- package/src/caddy/local-admin.test.ts +160 -0
- package/src/caddy/local-admin.ts +189 -0
- package/src/caddy/providers.test.ts +244 -0
- package/src/caddy/providers.ts +17 -0
- package/src/caddy/with-file.ts +62 -0
- package/src/cloudflare/fake-mesh.ts +181 -0
- package/src/cloudflare/index.ts +9 -3
- package/src/cloudflare/mesh-node-api.ts +127 -0
- package/src/cloudflare/mesh-node-edges.test.ts +103 -0
- package/src/cloudflare/mesh-node-form.test.ts +80 -0
- package/src/cloudflare/mesh-node-form.ts +115 -0
- package/src/cloudflare/mesh-node-harness.ts +124 -0
- package/src/cloudflare/mesh-node-lifecycle.ts +187 -0
- package/src/cloudflare/mesh-node-policy.test.ts +151 -0
- package/src/cloudflare/mesh-node-token.test.ts +117 -0
- package/src/cloudflare/mesh-node-token.ts +125 -0
- package/src/cloudflare/mesh-node.test.ts +172 -0
- package/src/cloudflare/mesh-node.ts +120 -0
- package/src/cloudflare/providers.ts +20 -2
- package/src/index.ts +1 -0
- package/src/launchd/secret-tripwire.ts +1 -1
- package/src/openbao/REPLACE.md +97 -21
- package/src/openbao/cloudflare-role-wire.test.ts +8 -34
- package/src/openbao/cloudflare-role.ts +30 -8
- package/src/openbao/fake-engines.ts +119 -0
- package/src/openbao/fake-stack.ts +112 -0
- package/src/openbao/policy-rename.test.ts +112 -0
- package/src/openbao/policy-wire.test.ts +7 -26
- package/src/openbao/policy.ts +27 -4
- package/src/openbao/proxmox-role-form.ts +27 -8
- package/src/openbao/proxmox-role-wire.ts +24 -0
- package/src/openbao/proxmox-role.ts +62 -37
- package/src/openbao/rename-occupied.test.ts +166 -0
- package/src/openbao/rename.ts +162 -0
- package/src/openbao/role-rename.test.ts +205 -0
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A fake Cloudflare `warp_connector` API for the MeshNode tests — an in-memory `fetch` handed to
|
|
3
|
+
* Effect's real FetchHttpClient through its `Fetch` reference, so the tests exercise distilled's
|
|
4
|
+
* real path assembly, query and body encoding, envelope decoding and error matching.
|
|
5
|
+
*
|
|
6
|
+
* ⛔ TEST-ONLY, AND NO NETWORK. No provider imports this file. The base URL is RFC 2606
|
|
7
|
+
* `api.example.com`, and any request outside `/accounts/<FAKE_ACCOUNT>/warp_connector` throws, so
|
|
8
|
+
* a mis-wired test fails instead of reaching a real API. The credentials are placeholders.
|
|
9
|
+
* ★ THE NAME FILTER IS A SUBSTRING MATCH ON PURPOSE. The real filter's exactness is undocumented;
|
|
10
|
+
* a loose fake proves the client re-checks the exact name.
|
|
11
|
+
* ★ PAGES OF TWO by default, so every lookup walks more than one page.
|
|
12
|
+
* ★ `is_deleted` IS HONOURED AS THE SDK DOCUMENTS IT: `false` → live only, `true` → deleted only,
|
|
13
|
+
* absent → every tunnel. So a lookup that forgot the filter sees tombstones and must drop them.
|
|
14
|
+
* ★ `onCreate` runs before a POST is answered: a test uses it to put a node in the way between
|
|
15
|
+
* the provider's lookup and its create — what a retried POST whose first response was lost does.
|
|
16
|
+
*/
|
|
17
|
+
import { type Credentials, fromApiToken } from '@distilled.cloud/cloudflare/Credentials';
|
|
18
|
+
import * as Cloudflare from 'alchemy/Cloudflare';
|
|
19
|
+
import * as Effect from 'effect/Effect';
|
|
20
|
+
import * as Layer from 'effect/Layer';
|
|
21
|
+
import * as Redacted from 'effect/Redacted';
|
|
22
|
+
import * as FetchHttpClient from 'effect/unstable/http/FetchHttpClient';
|
|
23
|
+
|
|
24
|
+
export const FAKE_BASE = 'https://api.example.com/client/v4';
|
|
25
|
+
export const FAKE_ACCOUNT = '00000000000000000000000000000001';
|
|
26
|
+
/** ⚠️ A placeholder, not a credential. Tests assert it is what the SDK sends. */
|
|
27
|
+
export const FAKE_API_TOKEN = 'placeholder-api-token';
|
|
28
|
+
|
|
29
|
+
export type FakeNode = {
|
|
30
|
+
id: string;
|
|
31
|
+
name: string;
|
|
32
|
+
ha: boolean;
|
|
33
|
+
status: string;
|
|
34
|
+
token: string;
|
|
35
|
+
deleted_at: string | null;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type Seen = { readonly method: string; readonly path: string; readonly body: unknown };
|
|
39
|
+
|
|
40
|
+
const ok = (result: unknown, extra: Record<string, unknown> = {}) =>
|
|
41
|
+
Response.json({ success: true, errors: [], messages: [], result, ...extra });
|
|
42
|
+
|
|
43
|
+
export const fakeFailure = (status: number, code: number, message: string) =>
|
|
44
|
+
Response.json(
|
|
45
|
+
{ success: false, errors: [{ code, message }], messages: [], result: null },
|
|
46
|
+
{ status },
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
/** The wire shape of a node, as the API documents it — plus `token` on create, as the HA page says. */
|
|
50
|
+
const wire = (node: FakeNode) => ({
|
|
51
|
+
id: node.id,
|
|
52
|
+
account_tag: FAKE_ACCOUNT,
|
|
53
|
+
created_at: '2026-09-21T00:00:00Z',
|
|
54
|
+
deleted_at: node.deleted_at,
|
|
55
|
+
name: node.name,
|
|
56
|
+
status: node.status,
|
|
57
|
+
tun_type: 'warp_connector',
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
export type FakeOptions = {
|
|
61
|
+
readonly perPage?: number;
|
|
62
|
+
/** Return a Response to answer the POST with it instead (e.g. `fakeFailure(409, 1013, …)`). */
|
|
63
|
+
readonly onCreate?: (name: string) => Response | void;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export const fakeMesh = (options: FakeOptions = {}) => {
|
|
67
|
+
const nodes = new Map<string, FakeNode>();
|
|
68
|
+
const seen: Seen[] = [];
|
|
69
|
+
const auth: string[] = [];
|
|
70
|
+
let next = 1;
|
|
71
|
+
const live = () => [...nodes.values()].filter((node) => node.deleted_at === null);
|
|
72
|
+
const listed = (isDeleted: string | null) =>
|
|
73
|
+
[...nodes.values()].filter((node) =>
|
|
74
|
+
isDeleted === null ? true : (node.deleted_at !== null) === (isDeleted === 'true'),
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
const seed = (node: Partial<FakeNode> & { name: string }): FakeNode => {
|
|
78
|
+
const id = node.id ?? `00000000-0000-4000-8000-${String(next++).padStart(12, '0')}`;
|
|
79
|
+
const full: FakeNode = {
|
|
80
|
+
id,
|
|
81
|
+
ha: false,
|
|
82
|
+
status: 'inactive',
|
|
83
|
+
token: `fake-node-token-${id}`,
|
|
84
|
+
deleted_at: null,
|
|
85
|
+
...node,
|
|
86
|
+
};
|
|
87
|
+
nodes.set(id, full);
|
|
88
|
+
return full;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const route = (method: string, url: URL, body: Record<string, unknown>): Response => {
|
|
92
|
+
const prefix = `/client/v4/accounts/${FAKE_ACCOUNT}/warp_connector`;
|
|
93
|
+
if (url.host !== 'api.example.com' || !url.pathname.startsWith(prefix)) {
|
|
94
|
+
throw new Error(`fake-mesh: unexpected request ${method} ${url.href}`);
|
|
95
|
+
}
|
|
96
|
+
const [id, sub] = url.pathname.slice(prefix.length).split('/').filter(Boolean);
|
|
97
|
+
if (id === undefined && method === 'GET') {
|
|
98
|
+
const filter = url.searchParams.get('name') ?? '';
|
|
99
|
+
const page = Number(url.searchParams.get('page') ?? '1');
|
|
100
|
+
const perPage = Number(url.searchParams.get('per_page') ?? options.perPage ?? 2);
|
|
101
|
+
const hits = listed(url.searchParams.get('is_deleted')).filter((node) =>
|
|
102
|
+
node.name.includes(filter),
|
|
103
|
+
);
|
|
104
|
+
const slice = hits.slice((page - 1) * perPage, page * perPage);
|
|
105
|
+
return ok(slice.map(wire), { result_info: { page, per_page: perPage, count: slice.length } });
|
|
106
|
+
}
|
|
107
|
+
if (id === undefined && method === 'POST') {
|
|
108
|
+
const name = String(body['name']);
|
|
109
|
+
const override = options.onCreate?.(name);
|
|
110
|
+
if (override !== undefined) return override;
|
|
111
|
+
if (live().some((node) => node.name === name)) {
|
|
112
|
+
return fakeFailure(409, 1013, 'Tunnel with name already exists');
|
|
113
|
+
}
|
|
114
|
+
const node = seed({ name, ha: body['ha'] === true });
|
|
115
|
+
return ok({ ...wire(node), token: node.token });
|
|
116
|
+
}
|
|
117
|
+
const node = id === undefined ? undefined : nodes.get(id);
|
|
118
|
+
if (node === undefined) return fakeFailure(404, 1002, 'Tunnel not found');
|
|
119
|
+
if (sub === 'token' && method === 'GET') return ok(node.token);
|
|
120
|
+
if (sub !== undefined) throw new Error(`fake-mesh: unexpected ${method} ${url.pathname}`);
|
|
121
|
+
if (method === 'GET') return ok(wire(node));
|
|
122
|
+
if (method === 'PATCH') {
|
|
123
|
+
const name = String(body['name']);
|
|
124
|
+
if (live().some((other) => other.name === name && other.id !== node.id)) {
|
|
125
|
+
return fakeFailure(409, 1013, 'Tunnel with name already exists');
|
|
126
|
+
}
|
|
127
|
+
node.name = name;
|
|
128
|
+
return ok(wire(node));
|
|
129
|
+
}
|
|
130
|
+
if (method === 'DELETE') {
|
|
131
|
+
if (node.deleted_at !== null) return fakeFailure(404, 1002, 'Tunnel not found');
|
|
132
|
+
node.deleted_at = '2026-09-21T01:00:00Z';
|
|
133
|
+
return ok(wire(node));
|
|
134
|
+
}
|
|
135
|
+
throw new Error(`fake-mesh: unexpected ${method} ${url.pathname}`);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const fetch = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
139
|
+
const request =
|
|
140
|
+
input instanceof Request ? new Request(input, init) : new Request(String(input), init);
|
|
141
|
+
const url = new URL(request.url);
|
|
142
|
+
const text = await request.text();
|
|
143
|
+
const body = text.length === 0 ? {} : (JSON.parse(text) as Record<string, unknown>);
|
|
144
|
+
seen.push({ method: request.method, path: `${url.pathname}${url.search}`, body });
|
|
145
|
+
auth.push(request.headers.get('authorization') ?? '');
|
|
146
|
+
return route(request.method, url, body);
|
|
147
|
+
}) as typeof globalThis.fetch;
|
|
148
|
+
|
|
149
|
+
return { auth, fetch, live, nodes, seed, seen };
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
export type FakeMesh = ReturnType<typeof fakeMesh>;
|
|
153
|
+
|
|
154
|
+
/** distilled Credentials + the real FetchHttpClient over the fake — what a Bun script provides. */
|
|
155
|
+
export const fakeClientLayer = (
|
|
156
|
+
fake: FakeMesh,
|
|
157
|
+
credentials: Layer.Layer<Credentials> = fromApiToken({
|
|
158
|
+
apiToken: FAKE_API_TOKEN,
|
|
159
|
+
apiBaseUrl: FAKE_BASE,
|
|
160
|
+
}),
|
|
161
|
+
) =>
|
|
162
|
+
Layer.mergeAll(
|
|
163
|
+
credentials,
|
|
164
|
+
FetchHttpClient.layer,
|
|
165
|
+
Layer.succeed(FetchHttpClient.Fetch, fake.fetch),
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
/** …plus the account, as Alchemy's CloudflareEnvironment hands it to a provider. */
|
|
169
|
+
export const fakeProviderLayer = (fake: FakeMesh, accountId: string = FAKE_ACCOUNT) =>
|
|
170
|
+
Layer.mergeAll(
|
|
171
|
+
fakeClientLayer(fake),
|
|
172
|
+
Layer.succeed(
|
|
173
|
+
Cloudflare.CloudflareEnvironment,
|
|
174
|
+
Effect.succeed({
|
|
175
|
+
type: 'apiToken' as const,
|
|
176
|
+
apiToken: Redacted.make(FAKE_API_TOKEN),
|
|
177
|
+
accountId,
|
|
178
|
+
source: { type: 'env' as const },
|
|
179
|
+
}),
|
|
180
|
+
),
|
|
181
|
+
);
|
package/src/cloudflare/index.ts
CHANGED
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Cloudflare providers for Alchemy — the gaps the vendor SDK leaves.
|
|
3
3
|
*
|
|
4
|
-
* ★ Built on
|
|
5
|
-
* `r2.buckets.locks.update/get` over the documented endpoints
|
|
6
|
-
*
|
|
4
|
+
* ★ Built on vendor SDKs, not hand-rolled HTTP. `R2BucketLock` uses the official `cloudflare`
|
|
5
|
+
* SDK, which already has `r2.buckets.locks.update/get` over the documented endpoints.
|
|
6
|
+
* `MeshNode` uses `@distilled.cloud/cloudflare`, the SDK Alchemy's own Cloudflare providers
|
|
7
|
+
* use, because `cloudflare@4.5.0` cannot create an HA node (mesh-node-api.ts). Either way
|
|
8
|
+
* there is no path string this package invents.
|
|
7
9
|
*/
|
|
8
10
|
export type { R2LockRule } from './lock-rules.ts';
|
|
9
11
|
export { R2BucketLock, type R2BucketLockProps } from './r2-bucket-lock.ts';
|
|
12
|
+
export { MeshNode, MeshNodeProvider, type MeshNodeServices } from './mesh-node.ts';
|
|
13
|
+
export type { MeshNodeAttributes, MeshNodeProps } from './mesh-node-form.ts';
|
|
14
|
+
export { MeshNodeError, type MeshNodeStatus } from './mesh-node-api.ts';
|
|
15
|
+
export { fetchMeshNodeToken, type MeshNodeRef } from './mesh-node-token.ts';
|
|
10
16
|
export { providers } from './providers.ts';
|
|
11
17
|
export {
|
|
12
18
|
astroWebsite,
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `warp_connector` calls a Cloudflare Mesh node needs, and nothing else.
|
|
3
|
+
*
|
|
4
|
+
* ★ A MESH NODE IS A `warp_connector` OBJECT. Cloudflare's Mesh docs create one with
|
|
5
|
+
* `POST /accounts/{account_id}/warp_connector` and fetch its enrolment token from
|
|
6
|
+
* `GET …/warp_connector/{id}/token` (developers.cloudflare.com/mesh/get-started, read
|
|
7
|
+
* 2026-09-21). There is no separate "mesh node" endpoint.
|
|
8
|
+
*
|
|
9
|
+
* ★ OVER `@distilled.cloud/cloudflare`, THE SDK ALCHEMY'S OWN CLOUDFLARE PROVIDERS USE, not the
|
|
10
|
+
* `cloudflare` npm SDK the R2 lock uses. Measured 2026-09-21: `cloudflare@4.5.0`'s
|
|
11
|
+
* `WARPConnectorCreateParams` has only `account_id` and `name`, so it cannot create an HA node.
|
|
12
|
+
* distilled `1.0.0-rc.12` (the version alchemy@2.0.0-beta.79 pins) has `ha?: boolean` on
|
|
13
|
+
* `CreateTunnelWarpConnectorRequest`, typed errors (`TunnelNotFound` code 1002 / 404,
|
|
14
|
+
* `DuplicateTunnelName` code 1013), and resolves credentials and the account exactly the way
|
|
15
|
+
* `Cloudflare.Tunnel.WarpConnector` and `Cloudflare.Gateway.Rule` do — so the node and the Gateway
|
|
16
|
+
* rules that pin its Mesh IP always land in the same account.
|
|
17
|
+
*
|
|
18
|
+
* ⛔ NO FUNCTION IN THIS FILE READS THE NODE TOKEN, and `observe` copies named fields only. The
|
|
19
|
+
* HA docs say the create response carries a `token` field; distilled's decoder copies only the
|
|
20
|
+
* fields in its schema (protocol.ts, `getProps(outputAst)`), and `observe` below would drop it
|
|
21
|
+
* again if it ever got through. The one token read lives in mesh-node-token.ts, on demand.
|
|
22
|
+
*/
|
|
23
|
+
import * as zeroTrust from '@distilled.cloud/cloudflare/zero-trust';
|
|
24
|
+
import * as Data from 'effect/Data';
|
|
25
|
+
import * as Effect from 'effect/Effect';
|
|
26
|
+
import * as Stream from 'effect/Stream';
|
|
27
|
+
|
|
28
|
+
/** `inactive` (never run), `degraded`, `healthy` or `down` — Cloudflare's own four. */
|
|
29
|
+
export type MeshNodeStatus = 'inactive' | 'degraded' | 'healthy' | 'down';
|
|
30
|
+
|
|
31
|
+
/** A live node as this package sees it. ⛔ There is no token field here, on purpose. */
|
|
32
|
+
export interface ObservedNode {
|
|
33
|
+
readonly id: string;
|
|
34
|
+
readonly name: string;
|
|
35
|
+
readonly status: MeshNodeStatus | undefined;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** A refusal or failure, carrying the sentence an operator needs. Never a token. */
|
|
39
|
+
export class MeshNodeError extends Data.TaggedError('MeshNodeError')<{
|
|
40
|
+
readonly message: string;
|
|
41
|
+
}> {}
|
|
42
|
+
|
|
43
|
+
type Raw = {
|
|
44
|
+
readonly id?: string | null;
|
|
45
|
+
readonly name?: string | null;
|
|
46
|
+
readonly status?: string | null;
|
|
47
|
+
readonly deletedAt?: string | null;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const STATUSES: readonly string[] = ['inactive', 'degraded', 'healthy', 'down'];
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* ⚠️ A DELETED NODE IS NOT A NODE. Cloudflare keeps deleted tunnels (every response carries
|
|
54
|
+
* `deleted_at`, and the list takes `is_deleted`), so a `GET` by id can answer for one. Treating
|
|
55
|
+
* that as live would "adopt" a tombstone and PATCH it.
|
|
56
|
+
*/
|
|
57
|
+
export const observe = (raw: Raw): ObservedNode | undefined => {
|
|
58
|
+
if (typeof raw.id !== 'string' || raw.id.length === 0) return undefined;
|
|
59
|
+
if (raw.deletedAt !== undefined && raw.deletedAt !== null) return undefined;
|
|
60
|
+
const status =
|
|
61
|
+
typeof raw.status === 'string' && STATUSES.includes(raw.status)
|
|
62
|
+
? (raw.status as MeshNodeStatus)
|
|
63
|
+
: undefined;
|
|
64
|
+
return { id: raw.id, name: raw.name ?? '', status };
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** The node with this id, or `undefined` when it is gone (404 / code 1002, or soft-deleted). */
|
|
68
|
+
export const getNode = (accountId: string, nodeId: string) =>
|
|
69
|
+
zeroTrust.getTunnelWarpConnector({ accountId, tunnelId: nodeId }).pipe(
|
|
70
|
+
Effect.map(observe),
|
|
71
|
+
Effect.catchTag('TunnelNotFound', () => Effect.succeed(undefined)),
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The live node with exactly this name, or `undefined`.
|
|
76
|
+
*
|
|
77
|
+
* ★ `name` is also sent as the list filter, but the match is re-checked here: the filter's
|
|
78
|
+
* exactness is not documented, and a prefix match would adopt the wrong node.
|
|
79
|
+
* ⛔ Two live matches is refused rather than resolved. Names are unique per account (the create
|
|
80
|
+
* answers code 1013 otherwise), so two means something this package does not understand.
|
|
81
|
+
*/
|
|
82
|
+
export const findNodeByName = (accountId: string, name: string) =>
|
|
83
|
+
zeroTrust.listTunnelWarpConnectors.items({ accountId, name, isDeleted: false }).pipe(
|
|
84
|
+
Stream.map(observe),
|
|
85
|
+
Stream.filter((node): node is ObservedNode => node !== undefined && node.name === name),
|
|
86
|
+
Stream.runCollect,
|
|
87
|
+
Effect.flatMap((chunk) => {
|
|
88
|
+
const matches = Array.from(chunk);
|
|
89
|
+
if (matches.length <= 1) return Effect.succeed(matches[0]);
|
|
90
|
+
const ids = matches.map((node) => node.id).join(', ');
|
|
91
|
+
return Effect.fail(
|
|
92
|
+
new MeshNodeError({
|
|
93
|
+
message: `Mesh node name "${name}" matches ${matches.length} live nodes (${ids}). Names are unique per account, so refusing to guess which one is meant.`,
|
|
94
|
+
}),
|
|
95
|
+
);
|
|
96
|
+
}),
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* ⛔ `ha` IS SENT ON CREATE AND NOWHERE ELSE. Cloudflare's HA page: "High availability is set at
|
|
101
|
+
* node creation time and cannot be changed afterward." The PATCH body (`PatchTunnelWarpConnector
|
|
102
|
+
* Request`) has only `name` and `tunnelSecret`.
|
|
103
|
+
*/
|
|
104
|
+
export const createNode = (accountId: string, name: string, ha: boolean) =>
|
|
105
|
+
zeroTrust.createTunnelWarpConnector({ accountId, name, ha }).pipe(
|
|
106
|
+
Effect.flatMap((raw) => {
|
|
107
|
+
const node = observe(raw);
|
|
108
|
+
return node === undefined
|
|
109
|
+
? Effect.fail(
|
|
110
|
+
new MeshNodeError({ message: `Creating Mesh node "${name}" returned no id.` }),
|
|
111
|
+
)
|
|
112
|
+
: Effect.succeed(node);
|
|
113
|
+
}),
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
/** ★ A rename is a PATCH: same id, same token, same enrolled replicas and Mesh IP. */
|
|
117
|
+
export const renameNode = (accountId: string, nodeId: string, name: string) =>
|
|
118
|
+
zeroTrust
|
|
119
|
+
.patchTunnelWarpConnector({ accountId, tunnelId: nodeId, name })
|
|
120
|
+
.pipe(Effect.map((raw) => observe(raw) ?? { id: nodeId, name, status: undefined }));
|
|
121
|
+
|
|
122
|
+
/** Idempotent: a node already gone (404 / code 1002) is a successful delete. */
|
|
123
|
+
export const deleteNode = (accountId: string, nodeId: string) =>
|
|
124
|
+
zeroTrust.deleteTunnelWarpConnector({ accountId, tunnelId: nodeId }).pipe(
|
|
125
|
+
Effect.asVoid,
|
|
126
|
+
Effect.catchTag('TunnelNotFound', () => Effect.void),
|
|
127
|
+
);
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MeshNodeProvider's refusals and recoveries — the cases a mutation of each guard must break.
|
|
3
|
+
*
|
|
4
|
+
* ★ Every test here was written against a guard that a mutation pass (2026-09-21) could delete
|
|
5
|
+
* without any other test noticing: the two-match refusal, the 404 on a stored id, the stored
|
|
6
|
+
* account in `read`, the rename collision, the `stables` list and the 1013-after-lookup race.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, test } from 'bun:test';
|
|
9
|
+
import { Unowned } from 'alchemy/AdoptPolicy';
|
|
10
|
+
import * as Effect from 'effect/Effect';
|
|
11
|
+
import { FAKE_ACCOUNT, fakeFailure, fakeMesh } from './fake-mesh.ts';
|
|
12
|
+
import { read, reconcile, run, stored } from './mesh-node-harness.ts';
|
|
13
|
+
|
|
14
|
+
const OTHER_ACCOUNT = '00000000000000000000000000000002';
|
|
15
|
+
const UNKNOWN_ID = '00000000-0000-4000-8000-00000000ffff';
|
|
16
|
+
|
|
17
|
+
describe('MeshNodeProvider edges', () => {
|
|
18
|
+
test('lookups filter by name and live-only on the wire', async () => {
|
|
19
|
+
const fake = fakeMesh();
|
|
20
|
+
fake.seed({ name: 'door-a', deleted_at: '2026-09-20T00:00:00Z' });
|
|
21
|
+
await run(fake, (p) => read(p, { name: 'door-a', ha: false }));
|
|
22
|
+
const list = fake.seen.find((s) => s.method === 'GET');
|
|
23
|
+
expect(list?.path).toContain('name=door-a');
|
|
24
|
+
expect(list?.path).toContain('is_deleted=false');
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('two live nodes with the declared name are refused, both named', async () => {
|
|
28
|
+
const fake = fakeMesh();
|
|
29
|
+
const a = fake.seed({ name: 'door-a' });
|
|
30
|
+
const b = fake.seed({ name: 'door-a' });
|
|
31
|
+
const failure = await run(fake, (p) => Effect.flip(read(p, { name: 'door-a', ha: false })));
|
|
32
|
+
expect(failure.message).toContain(a.id);
|
|
33
|
+
expect(failure.message).toContain(b.id);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('a stored id the API no longer knows (404): read falls back to the name, reconcile creates', async () => {
|
|
37
|
+
const fake = fakeMesh();
|
|
38
|
+
const node = fake.seed({ name: 'door-a' });
|
|
39
|
+
const gone = stored(FAKE_ACCOUNT, { id: UNKNOWN_ID, name: 'door-b' });
|
|
40
|
+
const probe = await run(fake, (p) => read(p, { name: 'door-a', ha: false }, gone));
|
|
41
|
+
expect(Unowned.is(probe)).toBe(true);
|
|
42
|
+
expect(probe).toMatchObject({ id: node.id });
|
|
43
|
+
|
|
44
|
+
const fresh = fakeMesh();
|
|
45
|
+
const created = await run(fresh, (p) => reconcile(p, { name: 'door-b', ha: true }, gone));
|
|
46
|
+
expect(created.id).not.toBe(UNKNOWN_ID);
|
|
47
|
+
expect(fresh.seen.find((s) => s.method === 'POST')?.body).toEqual({ name: 'door-b', ha: true });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('read refreshes in the account the node was written to, not the current environment', async () => {
|
|
51
|
+
const fake = fakeMesh();
|
|
52
|
+
const node = fake.seed({ name: 'door-a', status: 'healthy' });
|
|
53
|
+
const output = stored(FAKE_ACCOUNT, { id: node.id });
|
|
54
|
+
// ⚠️ The fake throws on any other account, so reading in OTHER_ACCOUNT fails this test.
|
|
55
|
+
const fresh = await run(
|
|
56
|
+
fake,
|
|
57
|
+
(p) => read(p, { name: 'door-a', ha: false }, output),
|
|
58
|
+
OTHER_ACCOUNT,
|
|
59
|
+
);
|
|
60
|
+
expect(fresh).toMatchObject({ id: node.id, accountId: FAKE_ACCOUNT, status: 'healthy' });
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test('a rename onto a taken name fails with a sentence and changes nothing', async () => {
|
|
64
|
+
const fake = fakeMesh();
|
|
65
|
+
fake.seed({ name: 'door-b' });
|
|
66
|
+
const node = fake.seed({ name: 'door-a' });
|
|
67
|
+
const output = stored(FAKE_ACCOUNT, { id: node.id });
|
|
68
|
+
const failure = await run(fake, (p) =>
|
|
69
|
+
Effect.flip(reconcile(p, { name: 'door-b', ha: false }, output)),
|
|
70
|
+
);
|
|
71
|
+
expect(failure.message).toContain('"door-b" already exists');
|
|
72
|
+
expect(fake.nodes.get(node.id)?.name).toBe('door-a');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('only id, accountId and ha are stable across an update (a rename changes name)', async () => {
|
|
76
|
+
const fake = fakeMesh();
|
|
77
|
+
const stables = await run(fake, (p) => Effect.succeed(p.stables));
|
|
78
|
+
expect(stables).toEqual(['id', 'accountId', 'ha']);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('a 1013 after a clean lookup names the node that appeared, and does not adopt it', async () => {
|
|
82
|
+
let appeared = '';
|
|
83
|
+
const fake = fakeMesh({
|
|
84
|
+
onCreate: (name) => {
|
|
85
|
+
// What a retried POST meets: the node its first, lost-response attempt created.
|
|
86
|
+
appeared = fake.seed({ name, ha: true }).id;
|
|
87
|
+
},
|
|
88
|
+
});
|
|
89
|
+
const failure = await run(fake, (p) => Effect.flip(reconcile(p, { name: 'door-a', ha: true })));
|
|
90
|
+
expect(failure.message).toContain(appeared);
|
|
91
|
+
expect(failure.message).toContain('adopt(true)');
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test('a 1013 with no live Mesh node of that name blames another tunnel type', async () => {
|
|
95
|
+
const fake = fakeMesh({
|
|
96
|
+
onCreate: () => fakeFailure(409, 1013, 'Tunnel with name already exists'),
|
|
97
|
+
});
|
|
98
|
+
const failure = await run(fake, (p) =>
|
|
99
|
+
Effect.flip(reconcile(p, { name: 'door-a', ha: false })),
|
|
100
|
+
);
|
|
101
|
+
expect(failure.message).toContain('another tunnel type');
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The pure half of MeshNode: which edits are an update and which a replace — and in which order.
|
|
3
|
+
*
|
|
4
|
+
* ★ The replace ORDER is the part worth testing. Names are unique per account, so an `ha` change
|
|
5
|
+
* that keeps the name must delete first; one that also renames must not, or it would take the
|
|
6
|
+
* node offline for no reason.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, test } from 'bun:test';
|
|
9
|
+
import * as Effect from 'effect/Effect';
|
|
10
|
+
import { type MeshNodeAttributes, diffMeshNode, validateMeshNode } from './mesh-node-form.ts';
|
|
11
|
+
|
|
12
|
+
const ACCOUNT = '00000000000000000000000000000001';
|
|
13
|
+
const output: MeshNodeAttributes = {
|
|
14
|
+
id: '00000000-0000-4000-8000-000000000001',
|
|
15
|
+
accountId: ACCOUNT,
|
|
16
|
+
name: 'door-a',
|
|
17
|
+
status: 'healthy',
|
|
18
|
+
ha: false,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
describe('diffMeshNode', () => {
|
|
22
|
+
test('no prior output: no opinion, the engine creates', () => {
|
|
23
|
+
expect(diffMeshNode({ name: 'door-a', ha: false }, undefined, ACCOUNT)).toBeUndefined();
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('nothing changed: no opinion', () => {
|
|
27
|
+
expect(diffMeshNode({ name: 'door-a', ha: false }, output, ACCOUNT)).toBeUndefined();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('a rename alone is an in-place update (PATCH keeps id, token and Mesh IPs)', () => {
|
|
31
|
+
expect(diffMeshNode({ name: 'door-b', ha: false }, output, ACCOUNT)).toEqual({
|
|
32
|
+
action: 'update',
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('ha changed under the same name: replace, delete first (names are unique)', () => {
|
|
37
|
+
expect(diffMeshNode({ name: 'door-a', ha: true }, output, ACCOUNT)).toEqual({
|
|
38
|
+
action: 'replace',
|
|
39
|
+
deleteFirst: true,
|
|
40
|
+
});
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('ha changed with a new name: replace, create first (nothing collides)', () => {
|
|
44
|
+
expect(diffMeshNode({ name: 'door-b', ha: true }, output, ACCOUNT)).toEqual({
|
|
45
|
+
action: 'replace',
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('ha changed while the name is unresolved: the order that cannot collide', () => {
|
|
50
|
+
const news = { name: Effect.succeed('door-a'), ha: true } as never;
|
|
51
|
+
expect(diffMeshNode(news, output, ACCOUNT)).toEqual({ action: 'replace', deleteFirst: true });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('an unresolved ha gives no answer; reconcile re-checks it', () => {
|
|
55
|
+
const news = { name: 'door-a', ha: Effect.succeed(true) } as never;
|
|
56
|
+
expect(diffMeshNode(news, output, ACCOUNT)).toBeUndefined();
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('another account is a create-first replace', () => {
|
|
60
|
+
const other = '00000000000000000000000000000002';
|
|
61
|
+
expect(diffMeshNode({ name: 'door-a', ha: false }, output, other)).toEqual({
|
|
62
|
+
action: 'replace',
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('validateMeshNode', () => {
|
|
68
|
+
test('accepts a plain declaration', () => {
|
|
69
|
+
expect(validateMeshNode({ name: 'door-a', ha: true })).toBeUndefined();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('refuses an empty or padded name, because adoption matches exactly', () => {
|
|
73
|
+
expect(validateMeshNode({ name: ' ', ha: false })?.message).toContain('non-empty');
|
|
74
|
+
expect(validateMeshNode({ name: ' door-a', ha: false })?.message).toContain('whitespace');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('refuses a missing ha: it is create-only, so there is no default', () => {
|
|
78
|
+
expect(validateMeshNode({ name: 'door-a' } as never)?.message).toContain('no default');
|
|
79
|
+
});
|
|
80
|
+
});
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Cloudflare.MeshNode` — its props, its attributes, and the pure `diff` that decides between
|
|
3
|
+
* update and replace.
|
|
4
|
+
*
|
|
5
|
+
* ⛔ THE ATTRIBUTES NEVER HOLD THE NODE TOKEN. That is the whole reason this resource exists:
|
|
6
|
+
* Alchemy beta.79's `Cloudflare.Tunnel.WarpConnector` calls `getTunnelWarpConnectorToken` in
|
|
7
|
+
* `toAttributes`, so EVERY read, reconcile and list fetches the token, and Alchemy state (which
|
|
8
|
+
* is not encrypted) stores it — `Redacted` hides it from logs, not from the state store. It also
|
|
9
|
+
* has no `ha` prop. The token is a runtime credential: fetch it on demand with
|
|
10
|
+
* `fetchMeshNodeToken` (mesh-node-token.ts) and write it straight to the host.
|
|
11
|
+
* ★ A BONUS OF NOT READING IT: the token endpoint needs a Write permission ("Cloudflare One
|
|
12
|
+
* Connectors Write", per its API reference, read 2026-09-21), so a plan over this resource can
|
|
13
|
+
* run on a read-only token. WarpConnector's cannot.
|
|
14
|
+
*/
|
|
15
|
+
import type { Diff } from 'alchemy/Diff';
|
|
16
|
+
import type { Input } from 'alchemy/Input';
|
|
17
|
+
import { MeshNodeError, type MeshNodeStatus } from './mesh-node-api.ts';
|
|
18
|
+
|
|
19
|
+
export interface MeshNodeProps {
|
|
20
|
+
/**
|
|
21
|
+
* The node's name in the account — and its identity when adopting (names are unique per
|
|
22
|
+
* account). ★ Changing it RENAMES the node in place (`PATCH`): the id, the token, the
|
|
23
|
+
* enrolled replicas and their Mesh IPs all survive, so a rename never re-enrols a host.
|
|
24
|
+
*/
|
|
25
|
+
readonly name: string;
|
|
26
|
+
/**
|
|
27
|
+
* High availability (active-passive replicas sharing one node token).
|
|
28
|
+
*
|
|
29
|
+
* ⛔ CREATE-ONLY, AND REQUIRED FOR THAT REASON. Cloudflare's Mesh HA page: "High availability
|
|
30
|
+
* is set at node creation time and cannot be changed afterward." The PATCH body carries no
|
|
31
|
+
* `ha`. So a change REPLACES the node — a new id, a new token, a new Mesh IP per replica —
|
|
32
|
+
* and a default would silently fix an irreversible choice. The API's own default is `false`;
|
|
33
|
+
* the dashboard's is `true`. Say which one you mean.
|
|
34
|
+
* ★ A NODE WITH NO ROUTES — A DOOR — IS `ha: false`, AND A SECOND DOOR IS A SECOND NODE. HA
|
|
35
|
+
* fails over the CIDR routes a node advertises; "Nodes without routes do not benefit from HA
|
|
36
|
+
* failover", because the node's Mesh IP is tied to the individual replica (same page, "When to
|
|
37
|
+
* use" and "Considerations", read 2026-09-21). After a failover the promoted replica answers
|
|
38
|
+
* on its own Mesh IP, so nothing that dials a door's first replica follows it. Declare the
|
|
39
|
+
* second door as its own `MeshNode` (own name, token, Mesh IP) and have callers list both.
|
|
40
|
+
* ⚠️ `ha` NEEDS MASQUE on the node's device profile (same page).
|
|
41
|
+
*/
|
|
42
|
+
readonly ha: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface MeshNodeAttributes {
|
|
46
|
+
/** The node's UUID (the `warp_connector` tunnel id). Pass it to `fetchMeshNodeToken`. */
|
|
47
|
+
readonly id: string;
|
|
48
|
+
/** The account the provider environment resolved when the node was written. */
|
|
49
|
+
readonly accountId: string;
|
|
50
|
+
readonly name: string;
|
|
51
|
+
/** As of the last read or write; it moves on its own as replicas connect and drop. */
|
|
52
|
+
readonly status: MeshNodeStatus | undefined;
|
|
53
|
+
/**
|
|
54
|
+
* The value the node was CREATED with (or declared with when adopted — see mesh-node.md).
|
|
55
|
+
* ⚠️ No documented read returns it: `GET /warp_connector/{id}` has no `ha` field (API
|
|
56
|
+
* reference, read 2026-09-21), so drift here cannot be observed, only prevented.
|
|
57
|
+
*/
|
|
58
|
+
readonly ha: boolean;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** ⛔ Fail closed on a declaration the API would accept but nobody meant. */
|
|
62
|
+
export const validateMeshNode = (props: MeshNodeProps): MeshNodeError | undefined => {
|
|
63
|
+
if (typeof props.name !== 'string' || props.name.trim().length === 0) {
|
|
64
|
+
return new MeshNodeError({ message: 'MeshNode `name` must be a non-empty string.' });
|
|
65
|
+
}
|
|
66
|
+
if (props.name !== props.name.trim()) {
|
|
67
|
+
return new MeshNodeError({
|
|
68
|
+
message: `MeshNode name "${props.name}" has leading or trailing whitespace; adoption matches names exactly.`,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
if (typeof props.ha !== 'boolean') {
|
|
72
|
+
return new MeshNodeError({
|
|
73
|
+
message: 'MeshNode `ha` must be true or false. It is create-only, so it has no default.',
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
return undefined;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* update or replace, from the declaration and what was last written.
|
|
81
|
+
*
|
|
82
|
+
* - Account changed → `replace`, create-first: names are unique per ACCOUNT, so the two
|
|
83
|
+
* generations cannot collide.
|
|
84
|
+
* - `ha` changed → `replace`. ⛔ `deleteFirst` when the name stays the same: the new node needs
|
|
85
|
+
* the name the old one holds, so create-first would fail with code 1013 (or, worse, a
|
|
86
|
+
* create path that "converged" on the old node would record an `ha` it does not have).
|
|
87
|
+
* Create-first when the name changes in the same edit, because then nothing collides.
|
|
88
|
+
* ⚠️ UNDER THE DEFAULT `retain` (mesh-node.ts) the engine skips the old node's delete in both
|
|
89
|
+
* orders: delete-first then REFUSES at the create (mesh-node-lifecycle.ts) until the deploy
|
|
90
|
+
* opts in with `RemovalPolicy.destroy()`, and create-first leaves the old node live, unmanaged.
|
|
91
|
+
* The answer here is the same under both policies: `diff` is not handed the policy (Provider.ts,
|
|
92
|
+
* its input), and delete-first is exactly what lets the opted-in deploy reuse the name.
|
|
93
|
+
* - Name changed alone → `update` (a PATCH rename).
|
|
94
|
+
* - Otherwise → no opinion; the engine compares props.
|
|
95
|
+
*
|
|
96
|
+
* ★ An unresolved `ha` gives no answer here; `reconcile` re-checks it once resolved and refuses
|
|
97
|
+
* rather than updating across a create-only change. An unresolved `name` next to a changed `ha`
|
|
98
|
+
* counts as "unchanged", which picks the delete-first order that cannot collide.
|
|
99
|
+
*/
|
|
100
|
+
export const diffMeshNode = (
|
|
101
|
+
news: Input<MeshNodeProps>,
|
|
102
|
+
output: MeshNodeAttributes | undefined,
|
|
103
|
+
accountId: string,
|
|
104
|
+
): Diff | undefined => {
|
|
105
|
+
if (output === undefined) return undefined;
|
|
106
|
+
if (output.accountId !== accountId) return { action: 'replace' };
|
|
107
|
+
const fields = news as { readonly name?: unknown; readonly ha?: unknown };
|
|
108
|
+
const name = typeof fields.name === 'string' ? fields.name : undefined;
|
|
109
|
+
if (typeof fields.ha === 'boolean' && fields.ha !== output.ha) {
|
|
110
|
+
const sameName = name === undefined || name === output.name;
|
|
111
|
+
return sameName ? { action: 'replace', deleteFirst: true } : { action: 'replace' };
|
|
112
|
+
}
|
|
113
|
+
if (name !== undefined && name !== output.name) return { action: 'update' };
|
|
114
|
+
return undefined;
|
|
115
|
+
};
|