@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,714 @@
|
|
|
1
|
+
// Cloudflare API v4 control-plane twin. The modeled slice is intentionally generic:
|
|
2
|
+
// account/zone discovery, remotely managed Cloudflare Tunnel provisioning, tunnel
|
|
3
|
+
// configuration/token retrieval, a bounded zone DNS-record control-plane slice, and the
|
|
4
|
+
// Workers/R2/Hyperdrive/Queues provisioning surface (cloudflare-provisioning.ts). It never
|
|
5
|
+
// opens a tunnel, resolves DNS, executes a Worker, stores an R2 object, dials an origin, or
|
|
6
|
+
// contacts Cloudflare. All state is the @volter/world-core projection.
|
|
7
|
+
//
|
|
8
|
+
// Grounding (read 2026-08-21, provisioning slice re-read 2026-08-23): Cloudflare's official
|
|
9
|
+
// api-schemas OpenAPI 4.0.0 (commit 2ac8369e9b63dccacee1a2284e95bb819f05b307) and official
|
|
10
|
+
// cloudflare-typescript SDK (commit faaaf89ed8064a9fb54de538ec3e89487f1302b0; npm cloudflare@7.1.0).
|
|
11
|
+
import { isIP } from 'node:net';
|
|
12
|
+
import { resolveSubjectId } from '@volter/world-core';
|
|
13
|
+
import {
|
|
14
|
+
CLOUDFLARE_RESOURCE_TYPES,
|
|
15
|
+
deterministicHex,
|
|
16
|
+
failure,
|
|
17
|
+
headers,
|
|
18
|
+
isResponse,
|
|
19
|
+
mint,
|
|
20
|
+
noRoute,
|
|
21
|
+
paginate,
|
|
22
|
+
parseBody,
|
|
23
|
+
readOnly,
|
|
24
|
+
row,
|
|
25
|
+
rows,
|
|
26
|
+
SERVICE,
|
|
27
|
+
scopeMissing,
|
|
28
|
+
success,
|
|
29
|
+
write,
|
|
30
|
+
callerEmail,
|
|
31
|
+
audit,
|
|
32
|
+
type CloudflareRequest,
|
|
33
|
+
type CloudflareResponse,
|
|
34
|
+
type CloudflareResourceType,
|
|
35
|
+
type Row,
|
|
36
|
+
} from './cloudflare-common.ts';
|
|
37
|
+
import { CLOUDFLARE_PROVISIONING_SURFACE, handleProvisioningRoute } from './cloudflare-provisioning.ts';
|
|
38
|
+
|
|
39
|
+
export { CLOUDFLARE_RESOURCE_TYPES } from './cloudflare-common.ts';
|
|
40
|
+
export type { CloudflareRequest, CloudflareResponse, CloudflareResourceType } from './cloudflare-common.ts';
|
|
41
|
+
|
|
42
|
+
const DNS_TYPES = new Set([
|
|
43
|
+
'A', 'AAAA', 'CAA', 'CERT', 'CNAME', 'DNSKEY', 'DS', 'HTTPS', 'LOC', 'MX', 'NAPTR',
|
|
44
|
+
'NS', 'OPENPGPKEY', 'PTR', 'SMIMEA', 'SRV', 'SSHFP', 'SVCB', 'TLSA', 'TXT', 'URI',
|
|
45
|
+
]);
|
|
46
|
+
const PROXIABLE_TYPES = new Set(['A', 'AAAA', 'CNAME']);
|
|
47
|
+
|
|
48
|
+
/** Tunnel configurations have no independent vendor id; Cloudflare addresses them by this pair. */
|
|
49
|
+
export function cloudflareConfigurationID(accountID: string, tunnelID: string): string {
|
|
50
|
+
return `${accountID}:${tunnelID}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function accountView(value: Row): Record<string, unknown> {
|
|
54
|
+
return {
|
|
55
|
+
id: value.id,
|
|
56
|
+
name: value.name,
|
|
57
|
+
type: value.account_type ?? 'standard',
|
|
58
|
+
created_on: value.created_on,
|
|
59
|
+
settings: value.settings ?? { enforce_twofactor: false },
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function zoneView(value: Row, root?: string): Record<string, unknown> {
|
|
64
|
+
const account = row('account', String(value.account_id), root);
|
|
65
|
+
return {
|
|
66
|
+
id: value.id,
|
|
67
|
+
name: value.name,
|
|
68
|
+
status: value.status ?? 'active',
|
|
69
|
+
paused: value.paused ?? false,
|
|
70
|
+
type: value.zone_type ?? 'full',
|
|
71
|
+
development_mode: value.development_mode ?? 0,
|
|
72
|
+
name_servers: value.name_servers ?? ['ada.ns.cloudflare.com', 'bob.ns.cloudflare.com'],
|
|
73
|
+
original_name_servers: value.original_name_servers ?? [],
|
|
74
|
+
original_registrar: value.original_registrar ?? null,
|
|
75
|
+
original_dnshost: value.original_dnshost ?? null,
|
|
76
|
+
modified_on: value.modified_on,
|
|
77
|
+
created_on: value.created_on,
|
|
78
|
+
activated_on: value.activated_on ?? value.created_on,
|
|
79
|
+
account: { id: value.account_id, name: account?.name ?? '' },
|
|
80
|
+
meta: value.meta ?? { step: 4, custom_certificate_quota: 0, page_rule_quota: 3, phishing_detected: false },
|
|
81
|
+
owner: { id: null, type: 'user', email: null },
|
|
82
|
+
tenant: { id: null, name: null },
|
|
83
|
+
tenant_unit: { id: null },
|
|
84
|
+
permissions: [],
|
|
85
|
+
plan: { id: '0feeeeeeeeeeeeeeeeeeeeeeeeeeeeee', name: 'Free Website', price: 0, currency: 'USD', frequency: '' },
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function tunnelView(value: Row): Record<string, unknown> {
|
|
90
|
+
return {
|
|
91
|
+
id: value.id,
|
|
92
|
+
account_tag: value.account_id,
|
|
93
|
+
config_src: value.config_src ?? 'cloudflare',
|
|
94
|
+
connections: [],
|
|
95
|
+
conns_active_at: null,
|
|
96
|
+
conns_inactive_at: null,
|
|
97
|
+
created_at: value.created_at,
|
|
98
|
+
deleted_at: value.deleted_at ?? null,
|
|
99
|
+
metadata: value.metadata ?? {},
|
|
100
|
+
name: value.name,
|
|
101
|
+
remote_config: value.config_src !== 'local',
|
|
102
|
+
status: value.status ?? 'inactive',
|
|
103
|
+
tun_type: 'cfd_tunnel',
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function configurationView(value: Row): Record<string, unknown> {
|
|
108
|
+
return {
|
|
109
|
+
account_id: value.account_id,
|
|
110
|
+
tunnel_id: value.tunnel_id,
|
|
111
|
+
source: 'cloudflare',
|
|
112
|
+
version: value.version ?? 1,
|
|
113
|
+
created_at: value.created_at,
|
|
114
|
+
config: value.config ?? { ingress: [] },
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function dnsView(value: Row): Record<string, unknown> {
|
|
119
|
+
const recordType = String(value.record_type);
|
|
120
|
+
return {
|
|
121
|
+
id: value.id,
|
|
122
|
+
zone_id: value.zone_id,
|
|
123
|
+
zone_name: value.zone_name,
|
|
124
|
+
name: value.name,
|
|
125
|
+
type: recordType,
|
|
126
|
+
...(typeof value.content === 'string' ? { content: value.content } : {}),
|
|
127
|
+
...(value.data !== null && typeof value.data === 'object' && !Array.isArray(value.data) ? { data: value.data } : {}),
|
|
128
|
+
...(typeof value.priority === 'number' ? { priority: value.priority } : {}),
|
|
129
|
+
...(typeof value.private_routing === 'boolean' ? { private_routing: value.private_routing } : {}),
|
|
130
|
+
proxiable: PROXIABLE_TYPES.has(recordType),
|
|
131
|
+
proxied: value.proxied ?? false,
|
|
132
|
+
ttl: value.ttl ?? 1,
|
|
133
|
+
settings: value.settings ?? {},
|
|
134
|
+
meta: value.meta ?? {},
|
|
135
|
+
comment: value.comment ?? null,
|
|
136
|
+
tags: value.tags ?? [],
|
|
137
|
+
created_on: value.created_on,
|
|
138
|
+
modified_on: value.modified_on,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function validTunnelSecret(value: unknown): boolean {
|
|
143
|
+
if (typeof value !== 'string' || !/^[A-Za-z0-9+/]+={0,2}$/.test(value) || value.length % 4 !== 0) return false;
|
|
144
|
+
const decoded = Buffer.from(value, 'base64');
|
|
145
|
+
return decoded.length >= 32 && decoded.toString('base64') === value;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function validAuth(req: CloudflareRequest): boolean {
|
|
149
|
+
const h = headers(req.headers);
|
|
150
|
+
return /^Bearer\s+\S+$/i.test(h.authorization ?? '') || Boolean(h['x-auth-key'] && h['x-auth-email']);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ── Who is calling, and the account audit log ───────────────────────────────────────────────────────────────────────
|
|
154
|
+
// A user API token minted at POST /twin/users/{email}/tokens (Cloudflare mints them in its dashboard) names its user; a
|
|
155
|
+
// write made with it is that user's in the account's audit log, as Cloudflare attributes an API token's changes to the
|
|
156
|
+
// user who owns it. Any other token names no one, and its changes are logged with an empty actor.
|
|
157
|
+
// GET /accounts/{id}/audit_logs: newest first unless direction=asc, bounded by since and before (ISO instants).
|
|
158
|
+
function auditLogRoute(req: CloudflareRequest, method: string, url: URL, parts: string[]): CloudflareResponse | null {
|
|
159
|
+
if (method !== 'GET' || parts[0] !== 'accounts' || !parts[1] || parts[2] !== 'audit_logs' || parts.length !== 3) return null;
|
|
160
|
+
if (!row('account', parts[1], req.root)) return scopeMissing('account', parts[1]);
|
|
161
|
+
const since = url.searchParams.get('since'), before = url.searchParams.get('before');
|
|
162
|
+
let values = rows('audit_log', req.root).filter((x) => x.account_id === parts[1] && (!since || String(x.when) >= since) && (!before || String(x.when) < before))
|
|
163
|
+
.sort((a, b) => String(a.when).localeCompare(String(b.when)));
|
|
164
|
+
if (url.searchParams.get('direction') !== 'asc') values = values.reverse();
|
|
165
|
+
const page = paginate(values.map(({ id, when, action, actor, interface: via, resource, oldValue, newValue, metadata, owner }) => ({ id, when, action, actor, interface: via, resource, oldValue, newValue, metadata, owner })), url, 100, 1000);
|
|
166
|
+
return success(page.result, page.info);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
async function bootstrap(req: CloudflareRequest, body: Record<string, unknown>): Promise<CloudflareResponse> {
|
|
170
|
+
const blocked = readOnly(req);
|
|
171
|
+
if (blocked) return blocked;
|
|
172
|
+
const observedAt = req.occurredAt ?? new Date().toISOString();
|
|
173
|
+
const accounts = Array.isArray(body.accounts) ? body.accounts : [];
|
|
174
|
+
const zones = Array.isArray(body.zones) ? body.zones : [];
|
|
175
|
+
for (const value of accounts) {
|
|
176
|
+
if (!value || typeof value !== 'object') return failure(400, 1004, 'Each bootstrap account must be an object');
|
|
177
|
+
const item = value as Record<string, unknown>;
|
|
178
|
+
if (typeof item.id !== 'string' || !/^[a-f0-9]{32}$/.test(item.id) || typeof item.name !== 'string') {
|
|
179
|
+
return failure(400, 1004, 'Bootstrap accounts require a 32-character lowercase hex id and name');
|
|
180
|
+
}
|
|
181
|
+
await write('account', item.id, 'account.observe', {
|
|
182
|
+
name: item.name,
|
|
183
|
+
account_type: item.type === 'enterprise' ? 'enterprise' : 'standard',
|
|
184
|
+
created_on: item.created_on ?? observedAt,
|
|
185
|
+
settings: item.settings ?? { enforce_twofactor: false },
|
|
186
|
+
}, req);
|
|
187
|
+
}
|
|
188
|
+
for (const value of zones) {
|
|
189
|
+
if (!value || typeof value !== 'object') return failure(400, 1004, 'Each bootstrap zone must be an object');
|
|
190
|
+
const item = value as Record<string, unknown>;
|
|
191
|
+
if (typeof item.id !== 'string' || !/^[a-f0-9]{32}$/.test(item.id) || typeof item.name !== 'string' || typeof item.account_id !== 'string') {
|
|
192
|
+
return failure(400, 1004, 'Bootstrap zones require id, name, and account_id');
|
|
193
|
+
}
|
|
194
|
+
if (!row('account', item.account_id, req.root)) return scopeMissing('account', item.account_id);
|
|
195
|
+
await write('zone', item.id, 'zone.observe', {
|
|
196
|
+
name: item.name.toLowerCase(),
|
|
197
|
+
account_id: item.account_id,
|
|
198
|
+
status: item.status ?? 'active',
|
|
199
|
+
paused: item.paused ?? false,
|
|
200
|
+
zone_type: item.type ?? 'full',
|
|
201
|
+
created_on: item.created_on ?? observedAt,
|
|
202
|
+
modified_on: item.modified_on ?? observedAt,
|
|
203
|
+
activated_on: item.activated_on ?? observedAt,
|
|
204
|
+
// the zone's name servers are Cloudflare's answer for it, so the TREE holds them and the view projects
|
|
205
|
+
// them — a refresh that reads this twin's own wire then observes exactly what the write stored
|
|
206
|
+
// (protocol 2 shape parity; they used to exist only in the view's default)
|
|
207
|
+
name_servers: item.name_servers ?? ['ada.ns.cloudflare.com', 'bob.ns.cloudflare.com'],
|
|
208
|
+
original_name_servers: item.original_name_servers ?? [],
|
|
209
|
+
}, req);
|
|
210
|
+
}
|
|
211
|
+
// Members with their user's two-factor state: the user's own record at Cloudflare, which no account API sets.
|
|
212
|
+
const members = Array.isArray(body.members) ? body.members : [];
|
|
213
|
+
for (const value of members) {
|
|
214
|
+
const item = (value ?? {}) as Record<string, unknown>;
|
|
215
|
+
if (typeof item.account_id !== 'string' || !row('account', item.account_id, req.root)) return failure(400, 1004, 'Bootstrap members require an existing account_id');
|
|
216
|
+
if (typeof item.email !== 'string' || !Array.isArray(item.roles)) return failure(400, 1004, 'Bootstrap members require an email and role names');
|
|
217
|
+
const roleIDs = (item.roles as unknown[]).map((name) => ROLES.find((r) => r.name === name)?.id);
|
|
218
|
+
if (!roleIDs.length || roleIDs.some((id) => !id)) return failure(400, 1004, `Bootstrap member roles must be among: ${ROLES.map((r) => r.name).join(', ')}`);
|
|
219
|
+
await write('account_member', mint('account_member', `${item.account_id}:${item.email}`, req.root), 'account_member.observe', { account_id: item.account_id, email: item.email, role_ids: roleIDs, status: item.status === 'pending' ? 'pending' : 'accepted', two_factor: item.two_factor === true }, req);
|
|
220
|
+
}
|
|
221
|
+
// A user's API tokens that already exist when the World starts (a token a person made before the story), as the minting
|
|
222
|
+
// door records one: {email, token}
|
|
223
|
+
const userTokens = Array.isArray(body.user_tokens) ? body.user_tokens : [];
|
|
224
|
+
for (const value of userTokens) {
|
|
225
|
+
const item = (value ?? {}) as Record<string, unknown>;
|
|
226
|
+
if (typeof item.email !== 'string' || typeof item.token !== 'string' || !item.token) return failure(400, 1004, 'Bootstrap user_tokens require an email and a token');
|
|
227
|
+
await write('user_token', item.token, 'user_token.observe', { token: item.token, email: item.email, created_on: req.occurredAt ?? new Date().toISOString() }, req);
|
|
228
|
+
}
|
|
229
|
+
return success({ accounts: accounts.length, zones: zones.length, members: members.length, user_tokens: userTokens.length });
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function dnsValidation(body: Record<string, unknown>, zoneID: string, root?: string, ignoreID?: string): CloudflareResponse | null {
|
|
233
|
+
if (typeof body.name !== 'string' || !body.name.trim()) return failure(400, 1004, 'DNS Validation Error: name is required');
|
|
234
|
+
const type = typeof body.type === 'string' ? body.type.toUpperCase() : '';
|
|
235
|
+
if (!DNS_TYPES.has(type)) return failure(400, 1004, 'DNS Validation Error: invalid record type');
|
|
236
|
+
const hasContent = typeof body.content === 'string' && body.content.length > 0;
|
|
237
|
+
const hasData = body.data !== null && typeof body.data === 'object' && !Array.isArray(body.data);
|
|
238
|
+
if (body.content !== undefined && !hasContent) return failure(400, 1004, 'DNS Validation Error: content must be a nonempty string');
|
|
239
|
+
if (body.data !== undefined && !hasData) return failure(400, 1004, 'DNS Validation Error: data must be an object');
|
|
240
|
+
if (type === 'A' && (!hasContent || isIP(String(body.content)) !== 4)) {
|
|
241
|
+
return failure(400, 1004, 'DNS Validation Error: A content must be a valid IPv4 address');
|
|
242
|
+
}
|
|
243
|
+
if (type === 'AAAA' && (!hasContent || isIP(String(body.content)) !== 6)) {
|
|
244
|
+
return failure(400, 1004, 'DNS Validation Error: AAAA content must be a valid IPv6 address');
|
|
245
|
+
}
|
|
246
|
+
if (type === 'CAA' && hasData) {
|
|
247
|
+
const data = body.data as Record<string, unknown>;
|
|
248
|
+
if (data.flags !== undefined && (!Number.isInteger(data.flags) || Number(data.flags) < 0 || Number(data.flags) > 255)) {
|
|
249
|
+
return failure(400, 1004, 'DNS Validation Error: CAA data.flags must be an integer between 0 and 255');
|
|
250
|
+
}
|
|
251
|
+
if (data.tag !== undefined && typeof data.tag !== 'string') return failure(400, 1004, 'DNS Validation Error: CAA data.tag must be a string');
|
|
252
|
+
if (data.value !== undefined && typeof data.value !== 'string') return failure(400, 1004, 'DNS Validation Error: CAA data.value must be a string');
|
|
253
|
+
}
|
|
254
|
+
if (body.priority !== undefined && (!Number.isInteger(body.priority) || Number(body.priority) < 0 || Number(body.priority) > 65535)) {
|
|
255
|
+
return failure(400, 1004, 'DNS Validation Error: priority must be an integer between 0 and 65535');
|
|
256
|
+
}
|
|
257
|
+
if (body.private_routing !== undefined && typeof body.private_routing !== 'boolean') {
|
|
258
|
+
return failure(400, 1004, 'DNS Validation Error: private_routing must be a boolean');
|
|
259
|
+
}
|
|
260
|
+
const ttl = body.ttl ?? 1;
|
|
261
|
+
if (typeof ttl !== 'number' || (ttl !== 1 && (ttl < 60 || ttl > 86400))) return failure(400, 1004, 'DNS Validation Error: TTL must be 1 or between 60 and 86400');
|
|
262
|
+
const sameName = rows('dns_record', root).filter((item) => item.zone_id === zoneID && item.name === body.name && item.id !== ignoreID);
|
|
263
|
+
if ((type === 'CNAME' && sameName.length > 0) || (type !== 'CNAME' && sameName.some((item) => item.record_type === 'CNAME'))) {
|
|
264
|
+
return failure(400, 81053, 'An A, AAAA, or CNAME record with that host already exists.');
|
|
265
|
+
}
|
|
266
|
+
if ((type === 'NS' && sameName.length > 0) || (type !== 'NS' && sameName.some((item) => item.record_type === 'NS'))) {
|
|
267
|
+
return failure(400, 81053, 'An NS record cannot coexist with another record at that name.');
|
|
268
|
+
}
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ── Account members and zone security settings ─────────────────────────────────────────────────────────────────
|
|
273
|
+
// The account's roles are Cloudflare's built-in ones (a fixed, deterministic set); a member holds role ids and has a
|
|
274
|
+
// status, and its user's two-factor state is Cloudflare's own record of the person (the bootstrap seeds it: nothing in
|
|
275
|
+
// the API lets an account set it). Zone settings are the two security settings modeled here, with Cloudflare's
|
|
276
|
+
// defaults until changed.
|
|
277
|
+
const ROLES = [
|
|
278
|
+
{ name: 'Super Administrator - All Privileges', description: 'Can edit any Cloudflare setting, make purchases, update billing, and manage memberships.' },
|
|
279
|
+
{ name: 'Administrator', description: 'Can access the full account, except for membership management and billing.' },
|
|
280
|
+
{ name: 'Administrator Read Only', description: 'Can access the full account in read-only mode.' },
|
|
281
|
+
{ name: 'Workers Admin', description: 'Can edit Workers, Workers routes and Workers KV, and deploy Workers.' },
|
|
282
|
+
].map((r) => ({ ...r, id: deterministicHex(`cf-role:${r.name}`, 32), permissions: {} }));
|
|
283
|
+
const ZONE_SETTINGS: Record<string, { default: string; values: string[] }> = {
|
|
284
|
+
min_tls_version: { default: '1.0', values: ['1.0', '1.1', '1.2', '1.3'] },
|
|
285
|
+
always_use_https: { default: 'off', values: ['on', 'off'] },
|
|
286
|
+
};
|
|
287
|
+
function memberView(value: Row): Record<string, unknown> {
|
|
288
|
+
return {
|
|
289
|
+
id: value.id, email: value.email, status: value.status ?? 'pending',
|
|
290
|
+
user: { id: value.user_id ?? null, email: value.email, first_name: null, last_name: null, two_factor_authentication_enabled: value.two_factor === true },
|
|
291
|
+
roles: ((value.role_ids ?? []) as string[]).map((id) => ROLES.find((r) => r.id === id)).filter(Boolean),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
async function membersRoute(req: CloudflareRequest, method: string, url: URL, parts: string[]): Promise<CloudflareResponse | null> {
|
|
295
|
+
if (parts[0] !== 'accounts' || !parts[1] || (parts[2] !== 'members' && parts[2] !== 'roles')) return null;
|
|
296
|
+
const accountID = parts[1];
|
|
297
|
+
if (!row('account', accountID, req.root)) return scopeMissing('account', accountID);
|
|
298
|
+
if (parts[2] === 'roles' && parts.length === 3 && method === 'GET') { const page = paginate(ROLES, url, 20, 50); return success(page.result, page.info); }
|
|
299
|
+
const members = () => rows('account_member', req.root).filter((m) => m.account_id === accountID);
|
|
300
|
+
if (parts[2] === 'members' && parts.length === 3 && method === 'GET') {
|
|
301
|
+
let values = members();
|
|
302
|
+
const status = url.searchParams.get('status');
|
|
303
|
+
if (status) values = values.filter((m) => (m.status ?? 'pending') === status);
|
|
304
|
+
const page = paginate(values.map(memberView), url, 20, 50);
|
|
305
|
+
return success(page.result, page.info);
|
|
306
|
+
}
|
|
307
|
+
if (parts[2] === 'members' && parts.length === 3 && method === 'POST') {
|
|
308
|
+
const blocked = readOnly(req); if (blocked) return blocked;
|
|
309
|
+
const body = parseBody(req); if (isResponse(body)) return body;
|
|
310
|
+
if (typeof body.email !== 'string' || !/^[^@\s]+@[^@\s]+$/.test(body.email)) return failure(400, 1001, 'A valid email is required');
|
|
311
|
+
const roleIDs = Array.isArray(body.roles) ? body.roles.map((r: unknown) => typeof r === 'string' ? r : (r as { id?: string })?.id) : [];
|
|
312
|
+
if (!roleIDs.length || roleIDs.some((id: unknown) => !ROLES.some((r) => r.id === id))) return failure(400, 1001, 'roles must name at least one role of the account');
|
|
313
|
+
if (members().some((m) => m.email === body.email)) return failure(400, 1001, 'That user is already a member of the account');
|
|
314
|
+
const id = mint('account_member', `${accountID}:${body.email}`, req.root);
|
|
315
|
+
await write('account_member', id, 'account_member.create', { account_id: accountID, email: body.email, role_ids: roleIDs, status: body.status === 'accepted' ? 'accepted' : 'pending', two_factor: false }, req);
|
|
316
|
+
await audit(req, accountID, { type: 'add', resource: { type: 'member', id }, newValue: { email: body.email, roles: roleIDs } });
|
|
317
|
+
return success(memberView(row('account_member', id, req.root)!));
|
|
318
|
+
}
|
|
319
|
+
const member = parts[3] ? members().find((m) => m.id === parts[3]) : undefined;
|
|
320
|
+
if (parts[2] === 'members' && parts.length === 4) {
|
|
321
|
+
if (!member) return failure(404, 1003, 'Member not found');
|
|
322
|
+
if (method === 'GET') return success(memberView(member));
|
|
323
|
+
if (method === 'DELETE') {
|
|
324
|
+
const blocked = readOnly(req); if (blocked) return blocked;
|
|
325
|
+
await write('account_member', member.id, 'account_member.delete', { deleted: true }, req);
|
|
326
|
+
await audit(req, accountID, { type: 'delete', resource: { type: 'member', id: String(member.id) }, oldValue: { email: member.email, roles: member.role_ids } });
|
|
327
|
+
return success({ id: member.id });
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
async function zoneSettingRoute(req: CloudflareRequest, method: string, parts: string[]): Promise<CloudflareResponse | null> {
|
|
333
|
+
if (parts[0] !== 'zones' || !parts[1] || parts[2] !== 'settings' || parts.length !== 4) return null;
|
|
334
|
+
const zone = row('zone', parts[1], req.root);
|
|
335
|
+
if (!zone) return scopeMissing('zone', parts[1]);
|
|
336
|
+
const def = ZONE_SETTINGS[parts[3]!];
|
|
337
|
+
if (!def) return null; // a real setting the twin does not model: the router's unmodeled-route answer, never a made-up refusal
|
|
338
|
+
const id = `${parts[1]}:${parts[3]}`;
|
|
339
|
+
const view = () => { const v = row('zone_setting', id, req.root); return { id: parts[3], value: v?.value ?? def.default, editable: true, modified_on: v?.modified_on ?? null }; };
|
|
340
|
+
if (method === 'GET') return success(view());
|
|
341
|
+
if (method === 'PATCH') {
|
|
342
|
+
const blocked = readOnly(req); if (blocked) return blocked;
|
|
343
|
+
const body = parseBody(req); if (isResponse(body)) return body;
|
|
344
|
+
if (typeof body.value !== 'string' || !def.values.includes(body.value)) return failure(400, 1007, `Invalid value for zone setting ${parts[3]}`);
|
|
345
|
+
const before = view().value;
|
|
346
|
+
await write('zone_setting', id, 'zone_setting.update', { zone_id: parts[1], setting: parts[3], value: body.value, modified_on: req.occurredAt ?? new Date().toISOString() }, req);
|
|
347
|
+
await audit(req, String(zone.account_id), { type: 'update', resource: { type: 'zone_setting', id: parts[3]! }, oldValue: before, newValue: body.value, metadata: { zone_id: parts[1], zone_name: zone.name, setting: parts[3] } });
|
|
348
|
+
return success(view());
|
|
349
|
+
}
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/** Main protocol handler. Unknown/unmodeled routes fail loudly with a Cloudflare v4 envelope. */
|
|
354
|
+
export async function handleCloudflareTwinRequest(req: CloudflareRequest): Promise<CloudflareResponse> {
|
|
355
|
+
const method = req.method.toUpperCase();
|
|
356
|
+
// The vendor default and world injector carry /client/v4; a public baseURL override may not.
|
|
357
|
+
// Both spellings reach the same router without modifying the SDK or its generated resources.
|
|
358
|
+
const wirePath = req.path.replace(/^\/client\/v4(?=\/|\?|$)/, '') || '/';
|
|
359
|
+
const url = new URL(wirePath, 'http://cloudflare.twin');
|
|
360
|
+
const parts = url.pathname.split('/').filter(Boolean).map(decodeURIComponent);
|
|
361
|
+
// protocol 2: after a landing adopted Cloudflare's id for an account, a zone, a tunnel or a record, a caller on a
|
|
362
|
+
// branch may still address it by the local id — resolved through the alias map once, here at the boundary
|
|
363
|
+
if (parts[0] === 'accounts' && parts[1]) parts[1] = resolveSubjectId(SERVICE, 'account', parts[1], req.root);
|
|
364
|
+
if (parts[0] === 'zones' && parts[1]) parts[1] = resolveSubjectId(SERVICE, 'zone', parts[1], req.root);
|
|
365
|
+
if (parts[0] === 'accounts' && parts[2] === 'cfd_tunnel' && parts[3]) parts[3] = resolveSubjectId(SERVICE, 'cloudflare_tunnel', parts[3], req.root);
|
|
366
|
+
if (parts[0] === 'zones' && parts[2] === 'dns_records' && parts[3]) parts[3] = resolveSubjectId(SERVICE, 'dns_record', parts[3], req.root);
|
|
367
|
+
|
|
368
|
+
if (!validAuth(req)) return failure(403, 10000, 'Authentication error');
|
|
369
|
+
|
|
370
|
+
// POST /twin/users/{email}/tokens: a user API token, as a user mints one in the dashboard.
|
|
371
|
+
if (method === 'POST' && parts[0] === 'twin' && parts[1] === 'users' && parts[2] && parts[3] === 'tokens' && parts.length === 4) {
|
|
372
|
+
const blocked = readOnly(req); if (blocked) return blocked;
|
|
373
|
+
if (!/^[^@\s]+@[^@\s]+$/.test(parts[2])) return failure(400, 1001, 'A valid email is required');
|
|
374
|
+
const token = `cf-${deterministicHex(`token:${parts[2]}:${rows('user_token', req.root).length}`, 40)}`;
|
|
375
|
+
await write('user_token', token, 'user_token.create', { token, email: parts[2], created_on: req.occurredAt ?? new Date().toISOString() }, req);
|
|
376
|
+
// A user's token is logged in each account the user is a member of.
|
|
377
|
+
for (const m of rows('account_member', req.root).filter((x) => x.email === parts[2])) await audit({ ...req, headers: { authorization: `Bearer ${token}` } }, String(m.account_id), { type: 'create', resource: { type: 'token', id: token.slice(0, 12) }, newValue: { owner: parts[2] } });
|
|
378
|
+
return success({ token, email: parts[2] });
|
|
379
|
+
}
|
|
380
|
+
// DELETE /user/tokens/{id}: a user revokes one of their own API tokens (the id is the first 12 characters the audit
|
|
381
|
+
// log names). Logged in each account the user belongs to.
|
|
382
|
+
if (method === 'DELETE' && parts[0] === 'user' && parts[1] === 'tokens' && parts[2] && parts.length === 3) {
|
|
383
|
+
const blocked = readOnly(req); if (blocked) return blocked;
|
|
384
|
+
const email = callerEmail(req);
|
|
385
|
+
const token = rows('user_token', req.root).find((t) => String(t.token).slice(0, 12) === parts[2] && t.email === email);
|
|
386
|
+
if (!email || !token) return failure(404, 1003, 'Token not found');
|
|
387
|
+
// Logged before the delete: a token that revokes itself is still its user's while the entry is written.
|
|
388
|
+
for (const m of rows('account_member', req.root).filter((x) => x.email === email)) await audit(req, String(m.account_id), { type: 'delete', resource: { type: 'token', id: parts[2] }, oldValue: { owner: email } });
|
|
389
|
+
await write('user_token', String(token.token), 'user_token.delete', { deleted: true }, req);
|
|
390
|
+
return success({ id: parts[2] });
|
|
391
|
+
}
|
|
392
|
+
const logged = auditLogRoute(req, method, url, parts);
|
|
393
|
+
if (logged) return logged;
|
|
394
|
+
|
|
395
|
+
if (method === 'POST' && parts.join('/') === 'twin/bootstrap') {
|
|
396
|
+
const body = parseBody(req);
|
|
397
|
+
return isResponse(body) ? body : bootstrap(req, body);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const member = await membersRoute(req, method, url, parts);
|
|
401
|
+
if (member) return member;
|
|
402
|
+
const setting = await zoneSettingRoute(req, method, parts);
|
|
403
|
+
if (setting) return setting;
|
|
404
|
+
|
|
405
|
+
// Workers / R2 / Hyperdrive / Queues provisioning control plane.
|
|
406
|
+
const provisioned = await handleProvisioningRoute(req, method, url, parts);
|
|
407
|
+
if (provisioned) return provisioned;
|
|
408
|
+
|
|
409
|
+
if (parts[0] === 'accounts' && parts.length === 1 && method === 'GET') {
|
|
410
|
+
let values = rows('account', req.root).map(accountView);
|
|
411
|
+
const name = url.searchParams.get('name');
|
|
412
|
+
if (name) values = values.filter((item) => item.name === name);
|
|
413
|
+
const page = paginate(values, url, 20, 50);
|
|
414
|
+
return success(page.result, page.info);
|
|
415
|
+
}
|
|
416
|
+
if (parts[0] === 'accounts' && parts.length === 2 && method === 'GET') {
|
|
417
|
+
const value = row('account', parts[1]!, req.root);
|
|
418
|
+
return value ? success(accountView(value)) : scopeMissing('account', parts[1]!);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
if (parts[0] === 'zones' && parts.length === 1 && method === 'GET') {
|
|
422
|
+
let values = rows('zone', req.root);
|
|
423
|
+
const accountID = url.searchParams.get('account.id');
|
|
424
|
+
const name = url.searchParams.get('name');
|
|
425
|
+
if (accountID) values = values.filter((item) => item.account_id === accountID);
|
|
426
|
+
if (name) values = values.filter((item) => item.name === name.toLowerCase());
|
|
427
|
+
const page = paginate(values.map((item) => zoneView(item, req.root)), url, 20, 50);
|
|
428
|
+
return success(page.result, page.info);
|
|
429
|
+
}
|
|
430
|
+
if (parts[0] === 'zones' && parts.length === 2 && method === 'GET') {
|
|
431
|
+
const value = row('zone', parts[1]!, req.root);
|
|
432
|
+
return value ? success(zoneView(value, req.root)) : scopeMissing('zone', parts[1]!);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
if (parts[0] === 'accounts' && parts[2] === 'cfd_tunnel') {
|
|
436
|
+
const accountID = parts[1]!;
|
|
437
|
+
if (!row('account', accountID, req.root)) return scopeMissing('account', accountID);
|
|
438
|
+
|
|
439
|
+
if (parts.length === 3 && method === 'GET') {
|
|
440
|
+
let values = rows('cloudflare_tunnel', req.root, true).filter((item) => item.account_id === accountID);
|
|
441
|
+
const includeDeleted = url.searchParams.get('is_deleted');
|
|
442
|
+
if (includeDeleted === 'true') values = values.filter((item) => item.deleted === true);
|
|
443
|
+
if (includeDeleted === 'false') values = values.filter((item) => item.deleted !== true);
|
|
444
|
+
const name = url.searchParams.get('name');
|
|
445
|
+
const uuid = url.searchParams.get('uuid');
|
|
446
|
+
if (name) values = values.filter((item) => item.name === name);
|
|
447
|
+
if (uuid) values = values.filter((item) => item.id === uuid);
|
|
448
|
+
const page = paginate(values.map(tunnelView), url, 100, 1000);
|
|
449
|
+
return success(page.result, page.info);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
if (parts.length === 3 && method === 'POST') {
|
|
453
|
+
const blocked = readOnly(req);
|
|
454
|
+
if (blocked) return blocked;
|
|
455
|
+
const body = parseBody(req);
|
|
456
|
+
if (isResponse(body)) return body;
|
|
457
|
+
if (typeof body.name !== 'string' || !body.name.trim()) return failure(400, 1004, 'name is required');
|
|
458
|
+
if (rows('cloudflare_tunnel', req.root).some((item) => item.account_id === accountID && item.name === body.name)) {
|
|
459
|
+
return failure(409, 1003, 'A tunnel with this name already exists');
|
|
460
|
+
}
|
|
461
|
+
const configSrc = body.config_src ?? 'cloudflare';
|
|
462
|
+
if (configSrc !== 'local' && configSrc !== 'cloudflare') return failure(400, 1004, 'config_src must be local or cloudflare');
|
|
463
|
+
if (body.tunnel_secret !== undefined) {
|
|
464
|
+
if (!validTunnelSecret(body.tunnel_secret)) return failure(400, 1004, 'tunnel_secret must be canonical base64 encoding of at least 32 bytes');
|
|
465
|
+
}
|
|
466
|
+
const id = mint('cloudflare_tunnel', `${accountID}:${body.name}`, req.root);
|
|
467
|
+
const createdAt = req.occurredAt ?? new Date().toISOString();
|
|
468
|
+
await write('cloudflare_tunnel', id, 'tunnel.create', {
|
|
469
|
+
account_id: accountID,
|
|
470
|
+
name: body.name,
|
|
471
|
+
config_src: configSrc,
|
|
472
|
+
tunnel_secret: body.tunnel_secret ?? null,
|
|
473
|
+
created_at: createdAt,
|
|
474
|
+
deleted_at: null,
|
|
475
|
+
metadata: {},
|
|
476
|
+
status: 'inactive',
|
|
477
|
+
}, req);
|
|
478
|
+
return success(tunnelView(row('cloudflare_tunnel', id, req.root)!));
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const tunnelID = parts[3];
|
|
482
|
+
if (!tunnelID) return failure(404, 7000, 'No route for that URI');
|
|
483
|
+
const tunnel = row('cloudflare_tunnel', tunnelID, req.root, true);
|
|
484
|
+
if (!tunnel || tunnel.account_id !== accountID || tunnel.deleted === true) return scopeMissing('tunnel', tunnelID);
|
|
485
|
+
|
|
486
|
+
if (parts.length === 4 && method === 'GET') return success(tunnelView(tunnel));
|
|
487
|
+
if (parts.length === 4 && method === 'PATCH') {
|
|
488
|
+
const blocked = readOnly(req);
|
|
489
|
+
if (blocked) return blocked;
|
|
490
|
+
const body = parseBody(req);
|
|
491
|
+
if (isResponse(body)) return body;
|
|
492
|
+
if (body.name !== undefined && (typeof body.name !== 'string' || !body.name.trim())) return failure(400, 1004, 'name must not be empty');
|
|
493
|
+
if (typeof body.name === 'string' && rows('cloudflare_tunnel', req.root).some((item) =>
|
|
494
|
+
item.account_id === accountID && item.id !== tunnelID && item.name === body.name)) {
|
|
495
|
+
return failure(409, 1003, 'A tunnel with this name already exists');
|
|
496
|
+
}
|
|
497
|
+
if (body.tunnel_secret !== undefined && !validTunnelSecret(body.tunnel_secret)) {
|
|
498
|
+
return failure(400, 1004, 'tunnel_secret must be canonical base64 encoding of at least 32 bytes');
|
|
499
|
+
}
|
|
500
|
+
await write('cloudflare_tunnel', tunnelID, 'tunnel.update', {
|
|
501
|
+
account_id: accountID,
|
|
502
|
+
...(body.name !== undefined ? { name: body.name } : {}),
|
|
503
|
+
...(body.tunnel_secret !== undefined ? { tunnel_secret: body.tunnel_secret } : {}),
|
|
504
|
+
}, req);
|
|
505
|
+
return success(tunnelView(row('cloudflare_tunnel', tunnelID, req.root)!));
|
|
506
|
+
}
|
|
507
|
+
if (parts.length === 4 && method === 'DELETE') {
|
|
508
|
+
const blocked = readOnly(req);
|
|
509
|
+
if (blocked) return blocked;
|
|
510
|
+
const deletedAt = req.occurredAt ?? new Date().toISOString();
|
|
511
|
+
await write('cloudflare_tunnel', tunnelID, 'tunnel.delete', { account_id: accountID, deleted: true, deleted_at: deletedAt }, req);
|
|
512
|
+
return success(tunnelView(row('cloudflare_tunnel', tunnelID, req.root, true)!));
|
|
513
|
+
}
|
|
514
|
+
if (parts.length === 5 && parts[4] === 'token' && method === 'GET') {
|
|
515
|
+
const token = `twin.${Buffer.from(JSON.stringify({ a: accountID, t: tunnelID })).toString('base64url')}.${deterministicHex(`${accountID}:${tunnelID}:token`, 48)}`;
|
|
516
|
+
return success(token);
|
|
517
|
+
}
|
|
518
|
+
if (parts.length === 5 && parts[4] === 'configurations') {
|
|
519
|
+
if (tunnel.config_src !== 'cloudflare') return failure(400, 1004, 'Tunnel is not remotely managed');
|
|
520
|
+
const current = rows('tunnel_configuration', req.root).find((item) => item.tunnel_id === tunnelID && item.account_id === accountID);
|
|
521
|
+
if (method === 'GET') {
|
|
522
|
+
if (!current) return failure(404, 1001, 'Tunnel configuration not found');
|
|
523
|
+
return success(configurationView(current));
|
|
524
|
+
}
|
|
525
|
+
if (method === 'PUT') {
|
|
526
|
+
const blocked = readOnly(req);
|
|
527
|
+
if (blocked) return blocked;
|
|
528
|
+
const body = parseBody(req);
|
|
529
|
+
if (isResponse(body)) return body;
|
|
530
|
+
const config = body.config;
|
|
531
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) return failure(400, 1004, 'config is required');
|
|
532
|
+
const ingress = (config as Record<string, unknown>).ingress;
|
|
533
|
+
if (!Array.isArray(ingress) || ingress.length === 0) return failure(400, 1004, 'At least one ingress rule is required');
|
|
534
|
+
for (const rule of ingress) {
|
|
535
|
+
const value = rule as Record<string, unknown> | null;
|
|
536
|
+
if (!value || typeof value !== 'object' || typeof value.hostname !== 'string'
|
|
537
|
+
|| !value.hostname || typeof value.service !== 'string' || !value.service) {
|
|
538
|
+
return failure(400, 1004, 'Each ingress rule requires nonempty hostname and service strings');
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
const id = current?.id ?? cloudflareConfigurationID(accountID, tunnelID);
|
|
542
|
+
await write('tunnel_configuration', id, 'tunnel_configuration.update', {
|
|
543
|
+
account_id: accountID,
|
|
544
|
+
tunnel_id: tunnelID,
|
|
545
|
+
source: 'cloudflare',
|
|
546
|
+
version: Number(current?.version ?? 0) + 1,
|
|
547
|
+
created_at: current?.created_at ?? (req.occurredAt ?? new Date().toISOString()),
|
|
548
|
+
config,
|
|
549
|
+
}, req);
|
|
550
|
+
return success(configurationView(row('tunnel_configuration', id, req.root)!));
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
return failure(404, 7000, 'No route for that URI');
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (parts[0] === 'zones' && parts[2] === 'dns_records') {
|
|
557
|
+
const zoneID = parts[1]!;
|
|
558
|
+
const zone = row('zone', zoneID, req.root);
|
|
559
|
+
if (!zone) return scopeMissing('zone', zoneID);
|
|
560
|
+
if (parts.length === 3 && method === 'GET') {
|
|
561
|
+
let values = rows('dns_record', req.root).filter((item) => item.zone_id === zoneID);
|
|
562
|
+
const type = url.searchParams.get('type');
|
|
563
|
+
const name = url.searchParams.get('name');
|
|
564
|
+
const content = url.searchParams.get('content');
|
|
565
|
+
if (type) values = values.filter((item) => item.record_type === type.toUpperCase());
|
|
566
|
+
if (name) values = values.filter((item) => item.name === name.toLowerCase());
|
|
567
|
+
if (content) values = values.filter((item) => item.content === content);
|
|
568
|
+
const page = paginate(values.map(dnsView), url, 100, 5_000_000);
|
|
569
|
+
return success(page.result, page.info);
|
|
570
|
+
}
|
|
571
|
+
if (parts.length === 3 && method === 'POST') {
|
|
572
|
+
const blocked = readOnly(req);
|
|
573
|
+
if (blocked) return blocked;
|
|
574
|
+
const body = parseBody(req);
|
|
575
|
+
if (isResponse(body)) return body;
|
|
576
|
+
if (typeof body.name === 'string') body.name = body.name.toLowerCase();
|
|
577
|
+
const invalid = dnsValidation(body, zoneID, req.root);
|
|
578
|
+
if (invalid) return invalid;
|
|
579
|
+
const id = mint('dns_record', `${zoneID}:${body.type}:${body.name}:${JSON.stringify(body.content ?? body.data)}`, req.root);
|
|
580
|
+
const at = req.occurredAt ?? new Date().toISOString();
|
|
581
|
+
await write('dns_record', id, 'dns_record.create', {
|
|
582
|
+
zone_id: zoneID,
|
|
583
|
+
zone_name: zone.name,
|
|
584
|
+
name: body.name,
|
|
585
|
+
record_type: String(body.type).toUpperCase(),
|
|
586
|
+
...(body.content !== undefined ? { content: body.content } : {}),
|
|
587
|
+
...(body.data !== undefined ? { data: body.data } : {}),
|
|
588
|
+
...(body.priority !== undefined ? { priority: body.priority } : {}),
|
|
589
|
+
...(body.private_routing !== undefined ? { private_routing: body.private_routing } : {}),
|
|
590
|
+
ttl: body.ttl ?? 1,
|
|
591
|
+
proxied: PROXIABLE_TYPES.has(String(body.type).toUpperCase()) ? body.proxied === true : false,
|
|
592
|
+
settings: body.settings ?? {},
|
|
593
|
+
meta: {},
|
|
594
|
+
comment: body.comment ?? null,
|
|
595
|
+
tags: body.tags ?? [],
|
|
596
|
+
created_on: at,
|
|
597
|
+
modified_on: at,
|
|
598
|
+
}, req);
|
|
599
|
+
return success(dnsView(row('dns_record', id, req.root)!));
|
|
600
|
+
}
|
|
601
|
+
const recordID = parts[3];
|
|
602
|
+
if (!recordID) return failure(404, 7000, 'No route for that URI');
|
|
603
|
+
// These are real collection subresources in Cloudflare's OpenAPI, but remain explicit
|
|
604
|
+
// manifest todos. Fail as an unmodeled route, never reinterpret their names as record IDs.
|
|
605
|
+
if (['batch', 'export', 'import', 'scan', 'usage'].includes(recordID)) return failure(404, 7000, 'No route for that URI');
|
|
606
|
+
const record = row('dns_record', recordID, req.root);
|
|
607
|
+
if (!record || record.zone_id !== zoneID) return failure(404, 81044, 'DNS Record not found');
|
|
608
|
+
if (parts.length === 4 && method === 'GET') return success(dnsView(record));
|
|
609
|
+
if (parts.length === 4 && (method === 'PUT' || method === 'PATCH')) {
|
|
610
|
+
const blocked = readOnly(req);
|
|
611
|
+
if (blocked) return blocked;
|
|
612
|
+
const body = parseBody(req);
|
|
613
|
+
if (isResponse(body)) return body;
|
|
614
|
+
if (method === 'PUT' && (body.name === undefined || body.type === undefined || body.ttl === undefined)) {
|
|
615
|
+
return failure(400, 1004, 'DNS Validation Error: PUT requires name, type, and ttl');
|
|
616
|
+
}
|
|
617
|
+
const merged: Record<string, unknown> = method === 'PUT' ? {
|
|
618
|
+
name: body.name,
|
|
619
|
+
type: body.type,
|
|
620
|
+
...(body.content !== undefined ? { content: body.content } : {}),
|
|
621
|
+
...(body.data !== undefined ? { data: body.data } : {}),
|
|
622
|
+
...(body.priority !== undefined ? { priority: body.priority } : {}),
|
|
623
|
+
...(body.private_routing !== undefined ? { private_routing: body.private_routing } : {}),
|
|
624
|
+
ttl: body.ttl,
|
|
625
|
+
proxied: body.proxied ?? false,
|
|
626
|
+
} : {
|
|
627
|
+
name: body.name ?? record.name,
|
|
628
|
+
type: body.type ?? record.record_type,
|
|
629
|
+
...(body.content !== undefined ? { content: body.content } : typeof record.content === 'string' ? { content: record.content } : {}),
|
|
630
|
+
...(body.data !== undefined ? { data: body.data } : record.data !== null && typeof record.data === 'object' && !Array.isArray(record.data) ? { data: record.data } : {}),
|
|
631
|
+
...(body.priority !== undefined ? { priority: body.priority } : typeof record.priority === 'number' ? { priority: record.priority } : {}),
|
|
632
|
+
...(body.private_routing !== undefined ? { private_routing: body.private_routing } : typeof record.private_routing === 'boolean' ? { private_routing: record.private_routing } : {}),
|
|
633
|
+
ttl: body.ttl ?? record.ttl,
|
|
634
|
+
proxied: body.proxied ?? record.proxied,
|
|
635
|
+
};
|
|
636
|
+
if (typeof merged.name === 'string') merged.name = merged.name.toLowerCase();
|
|
637
|
+
const invalid = dnsValidation(merged, zoneID, req.root, recordID);
|
|
638
|
+
if (invalid) return invalid;
|
|
639
|
+
const at = req.occurredAt ?? new Date().toISOString();
|
|
640
|
+
await write('dns_record', recordID, method === 'PUT' ? 'dns_record.replace' : 'dns_record.update', {
|
|
641
|
+
zone_id: zoneID,
|
|
642
|
+
name: merged.name,
|
|
643
|
+
record_type: String(merged.type).toUpperCase(),
|
|
644
|
+
content: merged.content ?? null,
|
|
645
|
+
data: merged.data ?? null,
|
|
646
|
+
priority: merged.priority ?? null,
|
|
647
|
+
private_routing: merged.private_routing ?? null,
|
|
648
|
+
ttl: merged.ttl,
|
|
649
|
+
proxied: PROXIABLE_TYPES.has(String(merged.type).toUpperCase()) ? merged.proxied === true : false,
|
|
650
|
+
...(method === 'PUT' ? {
|
|
651
|
+
settings: body.settings ?? {},
|
|
652
|
+
comment: body.comment ?? null,
|
|
653
|
+
tags: body.tags ?? [],
|
|
654
|
+
} : {
|
|
655
|
+
...(body.settings !== undefined ? { settings: body.settings } : {}),
|
|
656
|
+
...(body.comment !== undefined ? { comment: body.comment } : {}),
|
|
657
|
+
...(body.tags !== undefined ? { tags: body.tags } : {}),
|
|
658
|
+
}),
|
|
659
|
+
modified_on: at,
|
|
660
|
+
}, req);
|
|
661
|
+
return success(dnsView(row('dns_record', recordID, req.root)!));
|
|
662
|
+
}
|
|
663
|
+
if (parts.length === 4 && method === 'DELETE') {
|
|
664
|
+
const blocked = readOnly(req);
|
|
665
|
+
if (blocked) return blocked;
|
|
666
|
+
await write('dns_record', recordID, 'dns_record.delete', { zone_id: zoneID, deleted: true }, req);
|
|
667
|
+
return success({ id: recordID });
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
return noRoute();
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
export const CLOUDFLARE_CORE_SURFACE = [
|
|
675
|
+
'POST /twin/bootstrap',
|
|
676
|
+
'POST /twin/users/{email}/tokens',
|
|
677
|
+
'DELETE /user/tokens/{token_id}',
|
|
678
|
+
'GET /accounts/{account_id}/audit_logs',
|
|
679
|
+
'GET /accounts',
|
|
680
|
+
'GET /accounts/{account_id}',
|
|
681
|
+
'GET /zones',
|
|
682
|
+
'GET /zones/{zone_id}',
|
|
683
|
+
'GET /accounts/{account_id}/cfd_tunnel',
|
|
684
|
+
'POST /accounts/{account_id}/cfd_tunnel',
|
|
685
|
+
'GET /accounts/{account_id}/cfd_tunnel/{tunnel_id}',
|
|
686
|
+
'PATCH /accounts/{account_id}/cfd_tunnel/{tunnel_id}',
|
|
687
|
+
'DELETE /accounts/{account_id}/cfd_tunnel/{tunnel_id}',
|
|
688
|
+
'GET /accounts/{account_id}/cfd_tunnel/{tunnel_id}/token',
|
|
689
|
+
'GET /accounts/{account_id}/cfd_tunnel/{tunnel_id}/configurations',
|
|
690
|
+
'PUT /accounts/{account_id}/cfd_tunnel/{tunnel_id}/configurations',
|
|
691
|
+
'GET /zones/{zone_id}/dns_records',
|
|
692
|
+
'POST /zones/{zone_id}/dns_records',
|
|
693
|
+
'GET /zones/{zone_id}/dns_records/{dns_record_id}',
|
|
694
|
+
'PUT /zones/{zone_id}/dns_records/{dns_record_id}',
|
|
695
|
+
'PATCH /zones/{zone_id}/dns_records/{dns_record_id}',
|
|
696
|
+
'DELETE /zones/{zone_id}/dns_records/{dns_record_id}',
|
|
697
|
+
'GET /accounts/{account_id}/roles',
|
|
698
|
+
'GET /accounts/{account_id}/members',
|
|
699
|
+
'POST /accounts/{account_id}/members',
|
|
700
|
+
'GET /accounts/{account_id}/members/{member_id}',
|
|
701
|
+
'DELETE /accounts/{account_id}/members/{member_id}',
|
|
702
|
+
'GET /zones/{zone_id}/settings/{setting_id}',
|
|
703
|
+
'PATCH /zones/{zone_id}/settings/{setting_id}',
|
|
704
|
+
] as const;
|
|
705
|
+
|
|
706
|
+
export const CLOUDFLARE_ROUTER_SURFACE = [...CLOUDFLARE_CORE_SURFACE, ...CLOUDFLARE_PROVISIONING_SURFACE] as const;
|
|
707
|
+
|
|
708
|
+
export function cloudflareTwinSnapshot(root?: string): { resourceTypes: readonly string[]; implementedEndpoints: readonly string[]; counts: Record<string, number> } {
|
|
709
|
+
return {
|
|
710
|
+
resourceTypes: CLOUDFLARE_RESOURCE_TYPES,
|
|
711
|
+
implementedEndpoints: CLOUDFLARE_ROUTER_SURFACE,
|
|
712
|
+
counts: Object.fromEntries(CLOUDFLARE_RESOURCE_TYPES.map((type) => [type, rows(type, root).length])),
|
|
713
|
+
};
|
|
714
|
+
}
|