@volter/twin-cloudflare 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +223 -0
- package/dist/r2/src/endpoint.d.ts +14 -0
- package/dist/r2/src/endpoint.js +30 -0
- package/dist/r2/src/fetch.d.ts +7 -0
- package/dist/r2/src/fetch.js +230 -0
- package/dist/r2/src/generated/surface.gen.json +1 -0
- package/dist/r2/src/manifest.d.ts +22 -0
- package/dist/r2/src/manifest.js +69 -0
- package/dist/r2/src/semantics/buckets.d.ts +18 -0
- package/dist/r2/src/semantics/buckets.js +145 -0
- package/dist/r2/src/semantics/objects.d.ts +8 -0
- package/dist/r2/src/semantics/objects.js +252 -0
- package/dist/r2/src/wire.d.ts +54 -0
- package/dist/r2/src/wire.js +120 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +26 -0
- package/dist/src/cloudflare-budget.d.ts +22 -0
- package/dist/src/cloudflare-budget.js +40 -0
- package/dist/src/cloudflare-capabilities.d.ts +6 -0
- package/dist/src/cloudflare-capabilities.js +1161 -0
- package/dist/src/cloudflare-common.d.ts +59 -0
- package/dist/src/cloudflare-common.js +142 -0
- package/dist/src/cloudflare-conformance.d.ts +6 -0
- package/dist/src/cloudflare-conformance.js +251 -0
- package/dist/src/cloudflare-connector.d.ts +53 -0
- package/dist/src/cloudflare-connector.js +418 -0
- package/dist/src/cloudflare-openapi-operations.d.ts +10 -0
- package/dist/src/cloudflare-openapi-operations.js +3336 -0
- package/dist/src/cloudflare-provisioning.d.ts +5 -0
- package/dist/src/cloudflare-provisioning.js +2097 -0
- package/dist/src/cloudflare-r2.d.ts +121 -0
- package/dist/src/cloudflare-r2.js +138 -0
- package/dist/src/cloudflare-server.d.ts +17 -0
- package/dist/src/cloudflare-server.js +101 -0
- package/dist/src/cloudflare-twin.d.ts +14 -0
- package/dist/src/cloudflare-twin.js +762 -0
- package/dist/src/cloudflare-worker-assets.d.ts +25 -0
- package/dist/src/cloudflare-worker-assets.js +192 -0
- package/dist/src/index.d.ts +9 -0
- package/dist/src/index.js +69 -0
- package/dist/src/screens/dashboard.d.ts +3 -0
- package/dist/src/screens/dashboard.js +265 -0
- package/package.json +56 -0
- package/src/cli.ts +25 -0
- package/src/cloudflare-budget.ts +61 -0
- package/src/cloudflare-capabilities.ts +1107 -0
- package/src/cloudflare-common.ts +183 -0
- package/src/cloudflare-conformance.ts +204 -0
- package/src/cloudflare-connector.ts +443 -0
- package/src/cloudflare-openapi-operations.ts +3344 -0
- package/src/cloudflare-provisioning.ts +1969 -0
- package/src/cloudflare-r2.ts +173 -0
- package/src/cloudflare-server.ts +109 -0
- package/src/cloudflare-twin.ts +714 -0
- package/src/cloudflare-worker-assets.ts +207 -0
- package/src/index.ts +112 -0
- package/src/screens/dashboard.tsx +297 -0
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
// Cloudflare connector. All real I/O crosses the injected CloudflareExecute boundary.
|
|
2
|
+
// Pull folds mutable account/zone/tunnel/configuration/DNS state through the kernel's observe. Perform
|
|
3
|
+
// translates only explicitly modeled local actions and fails loudly on unknown writes.
|
|
4
|
+
import { assertBudgetGuardIntact, observeResources, projectResources } from '@volter/world-core';
|
|
5
|
+
import { CloudflareBudget, CloudflareBudgetError, cloudflareCallWeight } from "./cloudflare-budget.js";
|
|
6
|
+
import { cloudflareConfigurationID } from "./cloudflare-twin.js";
|
|
7
|
+
const SERVICE = 'cloudflare';
|
|
8
|
+
/** The single live Cloudflare call site. It is budgeted before fetch and records 429/Retry-After. */
|
|
9
|
+
export function liveCloudflareExecute(token, base = 'https://api.cloudflare.com/client/v4', opts = {}) {
|
|
10
|
+
const doFetch = opts.fetchImpl ?? fetch;
|
|
11
|
+
const budget = opts.budget !== undefined && opts.budget !== null
|
|
12
|
+
? assertBudgetGuardIntact(opts.budget, CloudflareBudget, 'liveCloudflareExecute')
|
|
13
|
+
: new CloudflareBudget({ token, ...(opts.budgetOptions ?? {}) });
|
|
14
|
+
return async (method, path, body) => {
|
|
15
|
+
const weight = cloudflareCallWeight(method, path);
|
|
16
|
+
const reservation = budget.checkBudget(weight);
|
|
17
|
+
const response = await doFetch(`${base}${path}`, {
|
|
18
|
+
method,
|
|
19
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
20
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
21
|
+
});
|
|
22
|
+
const responseHeaders = {};
|
|
23
|
+
response.headers.forEach((value, key) => { responseHeaders[key.toLowerCase()] = value; });
|
|
24
|
+
// recordCall may THROW after arming the cooldown (a back-off beyond the cap). On a refused call
|
|
25
|
+
// that louder refusal wins; an answer Cloudflare ACCEPTED is kept, so a write that landed is
|
|
26
|
+
// never recorded as failed and performed again on retry.
|
|
27
|
+
try {
|
|
28
|
+
budget.recordCall(weight, responseHeaders, { status: response.status, reservation });
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
if (!(error instanceof CloudflareBudgetError) || !response.ok)
|
|
32
|
+
throw error;
|
|
33
|
+
}
|
|
34
|
+
const text = await response.text();
|
|
35
|
+
let parsed = null;
|
|
36
|
+
if (text) {
|
|
37
|
+
try {
|
|
38
|
+
parsed = JSON.parse(text);
|
|
39
|
+
}
|
|
40
|
+
catch {
|
|
41
|
+
parsed = text;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return { status: response.status, body: parsed, headers: responseHeaders };
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
function resultOf(response, context) {
|
|
48
|
+
if (response.status < 200 || response.status >= 300
|
|
49
|
+
|| !response.body || typeof response.body !== 'object' || Array.isArray(response.body)
|
|
50
|
+
|| response.body.success !== true || !Object.hasOwn(response.body, 'result')) {
|
|
51
|
+
const detail = response.body?.errors?.[0]?.message ?? JSON.stringify(response.body);
|
|
52
|
+
throw new Error(`cloudflare pull ${context} refused: HTTP ${response.status} ${detail}`);
|
|
53
|
+
}
|
|
54
|
+
return response.body?.result;
|
|
55
|
+
}
|
|
56
|
+
function listResult(response, context) {
|
|
57
|
+
const result = resultOf(response, context);
|
|
58
|
+
if (!Array.isArray(result))
|
|
59
|
+
throw new Error(`cloudflare pull ${context} refused: result was not an array`);
|
|
60
|
+
return result;
|
|
61
|
+
}
|
|
62
|
+
function requiredString(value, key, context) {
|
|
63
|
+
const field = value[key];
|
|
64
|
+
if (typeof field !== 'string' || !field.trim()) {
|
|
65
|
+
throw new Error(`cloudflare pull ${context} refused: ${key} must be a nonempty string`);
|
|
66
|
+
}
|
|
67
|
+
return field;
|
|
68
|
+
}
|
|
69
|
+
// Account and zone ids anchor the `${account}:${name}` composite ids the provisioning slice
|
|
70
|
+
// mints (worker_script, r2_bucket, …). Cloudflare account/zone ids are 32-char lowercase hex;
|
|
71
|
+
// enforcing that on pull keeps those composite keyspaces unambiguous (a pulled id containing a
|
|
72
|
+
// colon could otherwise collide two keyspaces).
|
|
73
|
+
function requiredResourceID(value, key, context) {
|
|
74
|
+
const field = requiredString(value, key, context);
|
|
75
|
+
if (!/^[a-f0-9]{32}$/.test(field)) {
|
|
76
|
+
throw new Error(`cloudflare pull ${context} refused: ${key} must be a 32-character lowercase hex id`);
|
|
77
|
+
}
|
|
78
|
+
return field;
|
|
79
|
+
}
|
|
80
|
+
function optionalObject(value, key, context) {
|
|
81
|
+
if (value === undefined || value === null)
|
|
82
|
+
return undefined;
|
|
83
|
+
if (typeof value !== 'object' || Array.isArray(value)) {
|
|
84
|
+
throw new Error(`cloudflare pull ${context} refused: ${key} must be an object`);
|
|
85
|
+
}
|
|
86
|
+
return value;
|
|
87
|
+
}
|
|
88
|
+
function pagePath(path, page) {
|
|
89
|
+
const url = new URL(path, 'https://api.cloudflare.invalid');
|
|
90
|
+
url.searchParams.set('page', String(page));
|
|
91
|
+
return `${url.pathname}${url.search}`;
|
|
92
|
+
}
|
|
93
|
+
async function listAll(execute, path, context, inventory) {
|
|
94
|
+
const values = [];
|
|
95
|
+
for (let page = 1; page <= 10_000; page++) {
|
|
96
|
+
const response = await execute('GET', page === 1 ? path : pagePath(path, page));
|
|
97
|
+
values.push(...listResult(response, `${context} page ${page}`));
|
|
98
|
+
const info = response.body.result_info;
|
|
99
|
+
if (info === undefined) {
|
|
100
|
+
inventory.complete = false;
|
|
101
|
+
return values;
|
|
102
|
+
}
|
|
103
|
+
if (!info || typeof info !== 'object' || Array.isArray(info)) {
|
|
104
|
+
throw new Error(`cloudflare pull ${context} page ${page} refused: result_info must be an object`);
|
|
105
|
+
}
|
|
106
|
+
const totalPages = Number(info.total_pages);
|
|
107
|
+
const currentPage = Number(info.page ?? page);
|
|
108
|
+
if (!Number.isInteger(totalPages) || totalPages < 1 || !Number.isInteger(currentPage) || currentPage !== page) {
|
|
109
|
+
throw new Error(`cloudflare pull ${context} page ${page} refused: invalid result_info pagination`);
|
|
110
|
+
}
|
|
111
|
+
if (page >= totalPages)
|
|
112
|
+
return values;
|
|
113
|
+
}
|
|
114
|
+
throw new Error(`cloudflare pull ${context} refused: pagination exceeded 10000 pages`);
|
|
115
|
+
}
|
|
116
|
+
export function mapCloudflareAccount(value) {
|
|
117
|
+
const id = requiredResourceID(value, 'id', 'account fixture');
|
|
118
|
+
const name = requiredString(value, 'name', 'account fixture');
|
|
119
|
+
return {
|
|
120
|
+
type: 'account',
|
|
121
|
+
id,
|
|
122
|
+
fields: {
|
|
123
|
+
name,
|
|
124
|
+
account_type: value.type ?? 'standard',
|
|
125
|
+
created_on: value.created_on ?? null,
|
|
126
|
+
settings: value.settings ?? {},
|
|
127
|
+
_vendor_observed: true,
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
export function mapCloudflareZone(value) {
|
|
132
|
+
const id = requiredResourceID(value, 'id', 'zone fixture');
|
|
133
|
+
const name = requiredString(value, 'name', 'zone fixture');
|
|
134
|
+
const account = optionalObject(value.account, 'account', 'zone fixture');
|
|
135
|
+
const accountID = account?.id ?? value.account_id;
|
|
136
|
+
if (typeof accountID !== 'string' || !accountID)
|
|
137
|
+
throw new Error('cloudflare pull zone fixture refused: account id must be a nonempty string');
|
|
138
|
+
return {
|
|
139
|
+
type: 'zone',
|
|
140
|
+
id,
|
|
141
|
+
fields: {
|
|
142
|
+
name,
|
|
143
|
+
account_id: accountID,
|
|
144
|
+
status: value.status ?? 'active',
|
|
145
|
+
paused: value.paused ?? false,
|
|
146
|
+
zone_type: value.type ?? 'full',
|
|
147
|
+
created_on: value.created_on ?? null,
|
|
148
|
+
modified_on: value.modified_on ?? null,
|
|
149
|
+
activated_on: value.activated_on ?? null,
|
|
150
|
+
name_servers: value.name_servers ?? [],
|
|
151
|
+
original_name_servers: value.original_name_servers ?? [],
|
|
152
|
+
_vendor_observed: true,
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
export function mapCloudflareTunnel(value) {
|
|
157
|
+
const id = requiredString(value, 'id', 'tunnel fixture');
|
|
158
|
+
const accountID = requiredString(value, 'account_tag', 'tunnel fixture');
|
|
159
|
+
const name = requiredString(value, 'name', 'tunnel fixture');
|
|
160
|
+
return {
|
|
161
|
+
type: 'cloudflare_tunnel',
|
|
162
|
+
id,
|
|
163
|
+
fields: {
|
|
164
|
+
account_id: accountID,
|
|
165
|
+
name,
|
|
166
|
+
config_src: value.config_src ?? 'cloudflare',
|
|
167
|
+
created_at: value.created_at ?? null,
|
|
168
|
+
deleted_at: value.deleted_at ?? null,
|
|
169
|
+
metadata: value.metadata ?? {},
|
|
170
|
+
status: value.status ?? 'inactive',
|
|
171
|
+
deleted: value.deleted_at != null, // the kernel's tombstone, cleared by the subject's next write
|
|
172
|
+
_vendor_observed: true,
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
export function mapCloudflareConfiguration(value) {
|
|
177
|
+
const accountID = requiredString(value, 'account_id', 'configuration fixture');
|
|
178
|
+
const tunnelID = requiredString(value, 'tunnel_id', 'configuration fixture');
|
|
179
|
+
const config = optionalObject(value.config, 'config', 'configuration fixture');
|
|
180
|
+
if (!config)
|
|
181
|
+
throw new Error('cloudflare pull configuration fixture refused: config must be an object');
|
|
182
|
+
return {
|
|
183
|
+
type: 'tunnel_configuration',
|
|
184
|
+
id: cloudflareConfigurationID(accountID, tunnelID),
|
|
185
|
+
fields: {
|
|
186
|
+
account_id: accountID,
|
|
187
|
+
tunnel_id: tunnelID,
|
|
188
|
+
source: value.source ?? 'cloudflare',
|
|
189
|
+
version: value.version ?? 1,
|
|
190
|
+
created_at: value.created_at ?? null,
|
|
191
|
+
config,
|
|
192
|
+
_vendor_observed: true,
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
export function mapCloudflareDnsRecord(value) {
|
|
197
|
+
const id = requiredString(value, 'id', 'DNS record fixture');
|
|
198
|
+
const zoneID = requiredString(value, 'zone_id', 'DNS record fixture');
|
|
199
|
+
const name = requiredString(value, 'name', 'DNS record fixture');
|
|
200
|
+
const recordType = requiredString(value, 'type', 'DNS record fixture').toUpperCase();
|
|
201
|
+
if (value.content !== undefined && typeof value.content !== 'string')
|
|
202
|
+
throw new Error('cloudflare pull DNS record fixture refused: content must be a string');
|
|
203
|
+
const data = optionalObject(value.data, 'data', 'DNS record fixture');
|
|
204
|
+
return {
|
|
205
|
+
type: 'dns_record',
|
|
206
|
+
id,
|
|
207
|
+
fields: {
|
|
208
|
+
zone_id: zoneID,
|
|
209
|
+
zone_name: value.zone_name,
|
|
210
|
+
name,
|
|
211
|
+
record_type: recordType,
|
|
212
|
+
...(value.content !== undefined ? { content: value.content } : {}),
|
|
213
|
+
...(data !== undefined ? { data } : {}),
|
|
214
|
+
...(value.priority !== undefined ? { priority: value.priority } : {}),
|
|
215
|
+
...(value.private_routing !== undefined ? { private_routing: value.private_routing } : {}),
|
|
216
|
+
ttl: value.ttl ?? 1,
|
|
217
|
+
proxied: value.proxied ?? false,
|
|
218
|
+
settings: value.settings ?? {},
|
|
219
|
+
meta: value.meta ?? {},
|
|
220
|
+
comment: value.comment ?? null,
|
|
221
|
+
tags: value.tags ?? [],
|
|
222
|
+
created_on: value.created_on ?? null,
|
|
223
|
+
modified_on: value.modified_on ?? null,
|
|
224
|
+
_vendor_observed: true,
|
|
225
|
+
},
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
async function pullCloudflareSnapshot(execute) {
|
|
229
|
+
const inventory = { complete: true };
|
|
230
|
+
const accounts = await listAll(execute, '/accounts?per_page=50', 'accounts', inventory);
|
|
231
|
+
const resources = accounts.map(mapCloudflareAccount);
|
|
232
|
+
const zones = await listAll(execute, '/zones?per_page=50', 'zones', inventory);
|
|
233
|
+
resources.push(...zones.map(mapCloudflareZone));
|
|
234
|
+
for (const account of accounts) {
|
|
235
|
+
const accountID = String(account.id);
|
|
236
|
+
const tunnels = await listAll(execute, `/accounts/${encodeURIComponent(accountID)}/cfd_tunnel?per_page=100&is_deleted=false`, `tunnels for ${accountID}`, inventory);
|
|
237
|
+
resources.push(...tunnels.map(mapCloudflareTunnel));
|
|
238
|
+
for (const tunnel of tunnels) {
|
|
239
|
+
if ((tunnel.config_src ?? 'cloudflare') !== 'cloudflare')
|
|
240
|
+
continue;
|
|
241
|
+
const tunnelID = String(tunnel.id);
|
|
242
|
+
const response = await execute('GET', `/accounts/${encodeURIComponent(accountID)}/cfd_tunnel/${encodeURIComponent(tunnelID)}/configurations`);
|
|
243
|
+
if (response.status === 404)
|
|
244
|
+
continue;
|
|
245
|
+
resources.push(mapCloudflareConfiguration(resultOf(response, `configuration ${tunnelID}`)));
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
for (const zone of zones) {
|
|
249
|
+
const zoneID = String(zone.id);
|
|
250
|
+
const records = await listAll(execute, `/zones/${encodeURIComponent(zoneID)}/dns_records?per_page=5000000`, `DNS records for ${zoneID}`, inventory);
|
|
251
|
+
resources.push(...records.map(mapCloudflareDnsRecord));
|
|
252
|
+
}
|
|
253
|
+
return { resources, complete: inventory.complete };
|
|
254
|
+
}
|
|
255
|
+
export async function pullCloudflareState(execute) {
|
|
256
|
+
return (await pullCloudflareSnapshot(execute)).resources;
|
|
257
|
+
}
|
|
258
|
+
/** D7 pull entry point. Defaults to a moving timestamp so A→B→A observations cannot dedupe. */
|
|
259
|
+
export async function syncCloudflareFromReal(execute, opts = {}) {
|
|
260
|
+
const pulled = await pullCloudflareSnapshot(execute);
|
|
261
|
+
const occurredAt = opts.occurredAt ?? new Date().toISOString();
|
|
262
|
+
// protocol 2: the observation lands on the head through the kernel's fold — one batch, one instant. Identity is
|
|
263
|
+
// the kernel's too: a subject the head landed under the vendor's id is reached through the alias map, so the
|
|
264
|
+
// connector no longer keeps its own external-id table. A COMPLETE listing tombstones what it no longer holds
|
|
265
|
+
// (the kernel's `complete` sweep); an incomplete one never does, so a truncated page cannot erase an account.
|
|
266
|
+
const observed = pulled.resources.map((r) => ({ type: r.type, id: r.id, fields: r.fields }));
|
|
267
|
+
if (pulled.complete) {
|
|
268
|
+
// a tunnel Cloudflare no longer lists is deleted, and Cloudflare says so with a timestamp — the kernel's own
|
|
269
|
+
// sweep would tombstone it bare, so the tunnels are named here, with the instant, and left out of the sweep
|
|
270
|
+
const present = new Set(observed.filter((r) => r.type === 'cloudflare_tunnel').map((r) => r.id));
|
|
271
|
+
for (const resource of projectResources(SERVICE, opts.root)) {
|
|
272
|
+
if (resource.type !== 'cloudflare_tunnel' || present.has(resource.id))
|
|
273
|
+
continue;
|
|
274
|
+
if (resource.deleted === true)
|
|
275
|
+
continue;
|
|
276
|
+
observed.push({ type: 'cloudflare_tunnel', id: resource.id, fields: { deleted: true, deleted_at: occurredAt } });
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
const report = observeResources(SERVICE, observed, {
|
|
280
|
+
...(opts.root !== undefined ? { root: opts.root } : {}),
|
|
281
|
+
at: occurredAt,
|
|
282
|
+
batch: `obs:${SERVICE}:${occurredAt}`,
|
|
283
|
+
...(pulled.complete ? { complete: ['account', 'zone', 'tunnel_configuration', 'dns_record'] } : {}),
|
|
284
|
+
});
|
|
285
|
+
return { observed: report.observed, deltasAppended: report.appended };
|
|
286
|
+
}
|
|
287
|
+
// ── PROTOCOL 2: the state system's two adapters, over the kernel's executor ──────────
|
|
288
|
+
/** The pack's executor over the kernel's: the same API v4 call, carried by the head. */
|
|
289
|
+
export function cloudflareExecuteOver(execute) {
|
|
290
|
+
return async (method, path, body) => {
|
|
291
|
+
const res = await execute({
|
|
292
|
+
method,
|
|
293
|
+
path: path.startsWith('/client/v4') ? path : `/client/v4${path}`,
|
|
294
|
+
headers: { accept: 'application/json', authorization: 'Bearer twin', ...(body ? { 'content-type': 'application/json' } : {}) },
|
|
295
|
+
...(body ? { body: JSON.stringify(body) } : {}),
|
|
296
|
+
});
|
|
297
|
+
let parsed = {};
|
|
298
|
+
if (res.body) {
|
|
299
|
+
try {
|
|
300
|
+
parsed = JSON.parse(res.body);
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
parsed = { success: false, errors: [{ code: 0, message: res.body }] };
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
return { status: res.status, body: parsed };
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
/** The refresh adapter: pull the account's control plane through the executor and fold it into the root. */
|
|
310
|
+
export async function syncCloudflareFromRemote(execute, opts = {}) {
|
|
311
|
+
return syncCloudflareFromReal(cloudflareExecuteOver(execute), {
|
|
312
|
+
...(opts.root !== undefined ? { root: opts.root } : {}),
|
|
313
|
+
...(opts.occurredAt !== undefined ? { occurredAt: opts.occurredAt } : {}),
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
function dnsBody(fields) {
|
|
317
|
+
return {
|
|
318
|
+
name: fields.name,
|
|
319
|
+
type: fields.record_type,
|
|
320
|
+
ttl: fields.ttl,
|
|
321
|
+
proxied: fields.proxied,
|
|
322
|
+
...(typeof fields.content === 'string' ? { content: fields.content } : {}),
|
|
323
|
+
...(fields.data !== null && typeof fields.data === 'object' && !Array.isArray(fields.data) ? { data: fields.data } : {}),
|
|
324
|
+
...(typeof fields.priority === 'number' ? { priority: fields.priority } : {}),
|
|
325
|
+
...(typeof fields.private_routing === 'boolean' ? { private_routing: fields.private_routing } : {}),
|
|
326
|
+
...(fields.settings !== undefined ? { settings: fields.settings } : {}),
|
|
327
|
+
...(fields.comment !== undefined ? { comment: fields.comment } : {}),
|
|
328
|
+
...(fields.tags !== undefined ? { tags: fields.tags } : {}),
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
/** The one wire request an entry becomes, or null when the entry is the twin's own (an observation, a
|
|
332
|
+
* locally-authored provisioning record) and crosses nothing. `resolve` is the kernel's: a local id that a
|
|
333
|
+
* landing already adopted answers as the vendor's, so Cloudflare is asked about its own id, never ours. */
|
|
334
|
+
export function cloudflareRequestForAction(action, resolve = (_t, id) => id) {
|
|
335
|
+
const fields = (action.fields ?? {});
|
|
336
|
+
const id = resolve(action.subject.type, action.subject.id);
|
|
337
|
+
// The Workers/R2/Hyperdrive/Queues provisioning slice is locally-authored control-plane
|
|
338
|
+
// state the connector does not push yet (a filed pull-audit gap). These operations are
|
|
339
|
+
// unpushable-BY-DESIGN, exactly like account/zone observations — return null so the reconcile
|
|
340
|
+
// batch skips them and continues, rather than throwing and permanently wedging every later
|
|
341
|
+
// DNS/tunnel action behind an unmapped provisioning action. The default arm below still fails
|
|
342
|
+
// loudly for a GENUINELY unknown operation.
|
|
343
|
+
const provisioningType = new Set(['worker_script', 'worker_route', 'worker_domain', 'workers_subdomain', 'durable_object_namespace', 'r2_bucket', 'hyperdrive_config', 'queue', 'queue_consumer', 'worker_asset_upload_session', 'worker_asset', 'worker_version']);
|
|
344
|
+
if (provisioningType.has(action.subject.type))
|
|
345
|
+
return null;
|
|
346
|
+
switch (action.operation) {
|
|
347
|
+
case 'account.observe':
|
|
348
|
+
case 'zone.observe':
|
|
349
|
+
return null;
|
|
350
|
+
case 'tunnel.create':
|
|
351
|
+
return {
|
|
352
|
+
method: 'POST',
|
|
353
|
+
path: `/accounts/${encodeURIComponent(String(fields.account_id))}/cfd_tunnel`,
|
|
354
|
+
body: { name: fields.name, config_src: fields.config_src, ...(fields.tunnel_secret ? { tunnel_secret: fields.tunnel_secret } : {}) },
|
|
355
|
+
};
|
|
356
|
+
case 'tunnel.update':
|
|
357
|
+
return {
|
|
358
|
+
method: 'PATCH',
|
|
359
|
+
path: `/accounts/${encodeURIComponent(String(fields.account_id))}/cfd_tunnel/${encodeURIComponent(id)}`,
|
|
360
|
+
body: { ...(fields.name !== undefined ? { name: fields.name } : {}), ...(fields.tunnel_secret !== undefined ? { tunnel_secret: fields.tunnel_secret } : {}) },
|
|
361
|
+
};
|
|
362
|
+
case 'tunnel.delete':
|
|
363
|
+
return { method: 'DELETE', path: `/accounts/${encodeURIComponent(String(fields.account_id))}/cfd_tunnel/${encodeURIComponent(id)}` };
|
|
364
|
+
case 'tunnel_configuration.update':
|
|
365
|
+
return { method: 'PUT', path: `/accounts/${encodeURIComponent(String(fields.account_id))}/cfd_tunnel/${encodeURIComponent(resolve('cloudflare_tunnel', String(fields.tunnel_id)))}/configurations`, body: { config: fields.config } };
|
|
366
|
+
case 'dns_record.create':
|
|
367
|
+
return { method: 'POST', path: `/zones/${encodeURIComponent(String(fields.zone_id))}/dns_records`, body: dnsBody(fields) };
|
|
368
|
+
case 'dns_record.update':
|
|
369
|
+
return { method: 'PATCH', path: `/zones/${encodeURIComponent(String(fields.zone_id))}/dns_records/${encodeURIComponent(id)}`, body: dnsBody(fields) };
|
|
370
|
+
case 'dns_record.replace':
|
|
371
|
+
return { method: 'PUT', path: `/zones/${encodeURIComponent(String(fields.zone_id))}/dns_records/${encodeURIComponent(id)}`, body: dnsBody(fields) };
|
|
372
|
+
case 'dns_record.delete':
|
|
373
|
+
return { method: 'DELETE', path: `/zones/${encodeURIComponent(String(fields.zone_id))}/dns_records/${encodeURIComponent(id)}` };
|
|
374
|
+
default:
|
|
375
|
+
throw new Error(`cloudflare push: unsupported operation '${action.operation}' — refusing to silently drop it`);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
function pushResultIdentity(action, result, expectedID) {
|
|
379
|
+
if (!result || typeof result !== 'object' || Array.isArray(result)) {
|
|
380
|
+
throw new Error(`cloudflare push ${action.operation} refused: result must be a nonempty object`);
|
|
381
|
+
}
|
|
382
|
+
const value = result;
|
|
383
|
+
if (action.operation === 'tunnel_configuration.update') {
|
|
384
|
+
if (typeof value.tunnel_id !== 'string' || !value.tunnel_id) {
|
|
385
|
+
throw new Error('cloudflare push tunnel_configuration.update refused: result.tunnel_id must be a nonempty string');
|
|
386
|
+
}
|
|
387
|
+
if (value.tunnel_id !== expectedID) {
|
|
388
|
+
throw new Error('cloudflare push tunnel_configuration.update refused: result.tunnel_id did not match the requested vendor tunnel id');
|
|
389
|
+
}
|
|
390
|
+
return value.tunnel_id;
|
|
391
|
+
}
|
|
392
|
+
if (typeof value.id !== 'string' || !value.id) {
|
|
393
|
+
throw new Error(`cloudflare push ${action.operation} refused: result.id must be a nonempty string`);
|
|
394
|
+
}
|
|
395
|
+
if (action.operation !== 'tunnel.create' && action.operation !== 'dns_record.create' && value.id !== expectedID) {
|
|
396
|
+
throw new Error(`cloudflare push ${action.operation} refused: result.id did not match the requested vendor id`);
|
|
397
|
+
}
|
|
398
|
+
return value.id;
|
|
399
|
+
}
|
|
400
|
+
/** The perform adapter: one entry crosses to Cloudflare through the executor, and the vendor's own id for what
|
|
401
|
+
* it made comes back as the externalId (the kernel adopts it and every declared reference follows). An entry the
|
|
402
|
+
* vendor has nothing to write for — an observation, a provisioning record — crosses nothing and keeps its id. */
|
|
403
|
+
export async function performCloudflareAction(execute, action, ctx) {
|
|
404
|
+
const request = cloudflareRequestForAction(action, ctx.resolve);
|
|
405
|
+
if (!request)
|
|
406
|
+
return { externalId: action.subject.id, data: { performed: false, reason: `${action.operation ?? action.subject.type} is the twin's own record — nothing at Cloudflare to write` } };
|
|
407
|
+
const cloudflare = cloudflareExecuteOver(execute);
|
|
408
|
+
const response = await cloudflare(request.method, request.path, request.body);
|
|
409
|
+
const result = resultOf(response, `push ${action.operation}`);
|
|
410
|
+
const fields = (action.fields ?? {});
|
|
411
|
+
const expectedID = action.operation === 'tunnel_configuration.update'
|
|
412
|
+
? ctx.resolve('cloudflare_tunnel', String(fields.tunnel_id))
|
|
413
|
+
: ctx.resolve(action.subject.type, action.subject.id);
|
|
414
|
+
const returnedID = pushResultIdentity(action, result, expectedID);
|
|
415
|
+
// a create is the one entry whose id the vendor chooses; every other names the id it was asked about
|
|
416
|
+
const isCreate = action.operation === 'tunnel.create' || action.operation === 'dns_record.create';
|
|
417
|
+
return { externalId: isCreate ? returnedID : expectedID };
|
|
418
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type CloudflareOpenAPIOperation = {
|
|
2
|
+
id: string;
|
|
3
|
+
method: string;
|
|
4
|
+
path: string;
|
|
5
|
+
operationId: string;
|
|
6
|
+
summary: string;
|
|
7
|
+
area: string;
|
|
8
|
+
tier: 'core' | 'common' | 'niche';
|
|
9
|
+
};
|
|
10
|
+
export declare const CLOUDFLARE_OPENAPI_OPERATIONS: readonly CloudflareOpenAPIOperation[];
|