@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,25 @@
|
|
|
1
|
+
import { type CloudflareRequest, type CloudflareResponse } from './cloudflare-common.js';
|
|
2
|
+
export declare const ASSET_BUCKET_SIZE = 10;
|
|
3
|
+
type Body = Record<string, unknown>;
|
|
4
|
+
type ManifestEntry = {
|
|
5
|
+
hash: string;
|
|
6
|
+
size: number;
|
|
7
|
+
};
|
|
8
|
+
/** POST /accounts/{account_id}/workers/scripts/{script_name}/assets-upload-session */
|
|
9
|
+
export declare function createAssetsUploadSession(req: CloudflareRequest, accountID: string, scriptName: string, body: Body): Promise<CloudflareResponse>;
|
|
10
|
+
type Part = {
|
|
11
|
+
name: string;
|
|
12
|
+
filename?: string;
|
|
13
|
+
contentType?: string;
|
|
14
|
+
data: string;
|
|
15
|
+
};
|
|
16
|
+
/** POST /accounts/{account_id}/workers/assets/upload — authenticated by the session's upload jwt. */
|
|
17
|
+
export declare function uploadAssets(req: CloudflareRequest, accountID: string, url: URL, parseParts: (body: string, boundary: string) => Part[] | null): Promise<CloudflareResponse>;
|
|
18
|
+
export type RedeemedAssets = {
|
|
19
|
+
session_id: string;
|
|
20
|
+
manifest: Record<string, ManifestEntry>;
|
|
21
|
+
};
|
|
22
|
+
/** Redeem a script upload's `metadata.assets.jwt`: a completion token this twin issued for this
|
|
23
|
+
* account and Worker, whose session's every hash is held. */
|
|
24
|
+
export declare function redeemAssetsCompletion(req: CloudflareRequest, accountID: string, scriptName: string, jwt: unknown): RedeemedAssets | CloudflareResponse;
|
|
25
|
+
export {};
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// Workers static-assets upload: the three-step contract every assets deploy drives
|
|
2
|
+
// (wrangler's syncAssets, the official SDK's `workers.scripts.assets.upload` +
|
|
3
|
+
// `workers.assets.upload`):
|
|
4
|
+
//
|
|
5
|
+
// 1. POST /accounts/{account_id}/workers/scripts/{script_name}/assets-upload-session
|
|
6
|
+
// body {manifest: {"/path": {hash, size}}} → {jwt, buckets: [[hash, …], …]} — the buckets
|
|
7
|
+
// name only the hashes this account does not already hold; with nothing to upload the
|
|
8
|
+
// returned jwt is already the completion token.
|
|
9
|
+
// 2. POST /accounts/{account_id}/workers/assets/upload?base64=true, authenticated by the
|
|
10
|
+
// session jwt (not the API token), multipart parts named by hash carrying base64 content →
|
|
11
|
+
// {jwt} once every hash of the session is held (the completion token), {} before that.
|
|
12
|
+
// 3. The script upload's metadata.assets.jwt redeems the completion token; the script then
|
|
13
|
+
// records the session's manifest and the assets config (cloudflare-provisioning.ts).
|
|
14
|
+
//
|
|
15
|
+
// The twin RECORDS, never serves: an uploaded asset becomes a `worker_asset` row (hash, size,
|
|
16
|
+
// content type) — the bytes are decoded to check their size against the manifest and then
|
|
17
|
+
// dropped, because no Cloudflare read endpoint returns asset bytes. The manifest hash is
|
|
18
|
+
// wrangler's blake3 over base64 content + extension; the twin does not recompute blake3 (no
|
|
19
|
+
// such primitive in the runtime), so it verifies each part's decoded SIZE against the manifest
|
|
20
|
+
// and trusts the declared hash. Bucket size (at most ASSET_BUCKET_SIZE hashes) is a twin
|
|
21
|
+
// convention: the pinned OpenAPI types `buckets` but does not pin how the vendor partitions.
|
|
22
|
+
//
|
|
23
|
+
// Tokens are compact HS256 JWTs signed with a fixed twin key: clients decode the payload
|
|
24
|
+
// (wrangler reads `wrangler_single_asset_uploads` from it; the twin never sets it, so the bulk
|
|
25
|
+
// multipart path is the one driven) and the twin verifies the signature, purpose, account,
|
|
26
|
+
// and expiry on redemption.
|
|
27
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
28
|
+
import { failure, headers as lowerHeaders, isResponse, mint, row, rows, success, write, } from "./cloudflare-common.js";
|
|
29
|
+
const TOKEN_KEY = 'volter-cloudflare-twin:workers-assets';
|
|
30
|
+
const TOKEN_TTL_SECONDS = 3600;
|
|
31
|
+
export const ASSET_BUCKET_SIZE = 10;
|
|
32
|
+
const ASSET_HASH = /^[0-9a-f]{32}$/;
|
|
33
|
+
function b64url(value) {
|
|
34
|
+
return Buffer.from(value).toString('base64url');
|
|
35
|
+
}
|
|
36
|
+
function sign(input) {
|
|
37
|
+
return createHmac('sha256', TOKEN_KEY).update(input).digest('base64url');
|
|
38
|
+
}
|
|
39
|
+
function issueToken(claims) {
|
|
40
|
+
const head = `${b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))}.${b64url(JSON.stringify(claims))}`;
|
|
41
|
+
return `${head}.${sign(head)}`;
|
|
42
|
+
}
|
|
43
|
+
/** Verify a token's signature and shape; null when it is not one this twin issued. */
|
|
44
|
+
function readToken(token) {
|
|
45
|
+
const segments = token.split('.');
|
|
46
|
+
if (segments.length !== 3)
|
|
47
|
+
return null;
|
|
48
|
+
const expected = Buffer.from(sign(`${segments[0]}.${segments[1]}`));
|
|
49
|
+
const given = Buffer.from(segments[2]);
|
|
50
|
+
if (expected.length !== given.length || !timingSafeEqual(expected, given))
|
|
51
|
+
return null;
|
|
52
|
+
try {
|
|
53
|
+
const claims = JSON.parse(Buffer.from(segments[1], 'base64url').toString('utf8'));
|
|
54
|
+
if ((claims.purpose !== 'upload' && claims.purpose !== 'completion') || typeof claims.session_id !== 'string')
|
|
55
|
+
return null;
|
|
56
|
+
return claims;
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
function epochSeconds(req) {
|
|
63
|
+
return Math.floor(Date.parse(req.occurredAt ?? new Date().toISOString()) / 1000);
|
|
64
|
+
}
|
|
65
|
+
function assetID(accountID, hash) {
|
|
66
|
+
return `${accountID}:${hash}`;
|
|
67
|
+
}
|
|
68
|
+
function heldHashes(accountID, root) {
|
|
69
|
+
return new Set(rows('worker_asset', root).filter((item) => item.account_id === accountID).map((item) => String(item.hash)));
|
|
70
|
+
}
|
|
71
|
+
function sessionComplete(session, root) {
|
|
72
|
+
const held = heldHashes(String(session.account_id), root);
|
|
73
|
+
return (Array.isArray(session.pending) ? session.pending : []).every((hash) => held.has(hash));
|
|
74
|
+
}
|
|
75
|
+
function completionToken(session, req) {
|
|
76
|
+
const iat = epochSeconds(req);
|
|
77
|
+
return issueToken({
|
|
78
|
+
purpose: 'completion', account_id: String(session.account_id), script_name: String(session.script_name),
|
|
79
|
+
session_id: session.id, iat, exp: iat + TOKEN_TTL_SECONDS,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
function validManifest(value) {
|
|
83
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
84
|
+
return failure(400, 1004, 'manifest is required and must be an object of path → {hash, size}');
|
|
85
|
+
const manifest = {};
|
|
86
|
+
// Stored in path order: a client builds its manifest concurrently (wrangler's insertion order
|
|
87
|
+
// varies run to run), and the same assets must record — and fingerprint — the same way.
|
|
88
|
+
for (const [path, entry] of Object.entries(value).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))) {
|
|
89
|
+
if (!path.startsWith('/'))
|
|
90
|
+
return failure(400, 1004, `manifest path "${path}" must start with "/"`);
|
|
91
|
+
const item = entry;
|
|
92
|
+
if (!item || typeof item !== 'object' || typeof item.hash !== 'string' || !ASSET_HASH.test(item.hash)) {
|
|
93
|
+
return failure(400, 1004, `manifest entry "${path}" requires a 32-character hex hash`);
|
|
94
|
+
}
|
|
95
|
+
if (!Number.isInteger(item.size) || Number(item.size) < 0)
|
|
96
|
+
return failure(400, 1004, `manifest entry "${path}" requires a non-negative integer size`);
|
|
97
|
+
manifest[path] = { hash: item.hash, size: Number(item.size) };
|
|
98
|
+
}
|
|
99
|
+
return manifest;
|
|
100
|
+
}
|
|
101
|
+
/** POST /accounts/{account_id}/workers/scripts/{script_name}/assets-upload-session */
|
|
102
|
+
export async function createAssetsUploadSession(req, accountID, scriptName, body) {
|
|
103
|
+
const entries = validManifest(body.manifest);
|
|
104
|
+
if (isResponse(entries))
|
|
105
|
+
return entries;
|
|
106
|
+
const held = heldHashes(accountID, req.root);
|
|
107
|
+
const pending = [...new Set(Object.values(entries).map((entry) => entry.hash))].filter((hash) => !held.has(hash)).sort();
|
|
108
|
+
const buckets = [];
|
|
109
|
+
for (let index = 0; index < pending.length; index += ASSET_BUCKET_SIZE)
|
|
110
|
+
buckets.push(pending.slice(index, index + ASSET_BUCKET_SIZE));
|
|
111
|
+
const at = req.occurredAt ?? new Date().toISOString();
|
|
112
|
+
const id = mint('worker_asset_upload_session', `${accountID}:${scriptName}:${JSON.stringify(entries)}`, req.root);
|
|
113
|
+
await write('worker_asset_upload_session', id, 'worker_asset_upload_session.create', {
|
|
114
|
+
account_id: accountID, script_name: scriptName, manifest: entries, pending, created_on: at, deleted: false,
|
|
115
|
+
}, req);
|
|
116
|
+
const session = row('worker_asset_upload_session', id, req.root);
|
|
117
|
+
const iat = epochSeconds(req);
|
|
118
|
+
const jwt = pending.length === 0
|
|
119
|
+
? completionToken(session, req)
|
|
120
|
+
: issueToken({ purpose: 'upload', account_id: accountID, script_name: scriptName, session_id: id, iat, exp: iat + TOKEN_TTL_SECONDS });
|
|
121
|
+
return success({ jwt, buckets });
|
|
122
|
+
}
|
|
123
|
+
/** POST /accounts/{account_id}/workers/assets/upload — authenticated by the session's upload jwt. */
|
|
124
|
+
export async function uploadAssets(req, accountID, url, parseParts) {
|
|
125
|
+
const h = lowerHeaders(req.headers);
|
|
126
|
+
const bearer = /^Bearer\s+(\S+)$/i.exec(h.authorization ?? '')?.[1] ?? '';
|
|
127
|
+
const claims = readToken(bearer);
|
|
128
|
+
if (!claims || claims.purpose !== 'upload' || claims.account_id !== accountID) {
|
|
129
|
+
return failure(401, 10000, 'Authentication error: the asset upload requires the upload session token');
|
|
130
|
+
}
|
|
131
|
+
if (claims.exp < epochSeconds(req))
|
|
132
|
+
return failure(401, 10000, 'Authentication error: the asset upload session token has expired');
|
|
133
|
+
const session = row('worker_asset_upload_session', claims.session_id, req.root);
|
|
134
|
+
if (!session || session.account_id !== accountID)
|
|
135
|
+
return failure(401, 10000, 'Authentication error: unknown asset upload session');
|
|
136
|
+
const boundary = /boundary\s*=\s*(?:"([^"]+)"|([^\s;]+))/i.exec(h['content-type'] ?? '');
|
|
137
|
+
const parts = boundary ? parseParts(req.body ?? '', boundary[1] ?? boundary[2]) : null;
|
|
138
|
+
if (!parts || parts.length === 0)
|
|
139
|
+
return failure(400, 1004, 'Asset upload requires a multipart/form-data body with one part per asset hash');
|
|
140
|
+
const base64 = url.searchParams.get('base64') === 'true';
|
|
141
|
+
const manifest = session.manifest;
|
|
142
|
+
const sizeOf = new Map(Object.values(manifest).map((entry) => [entry.hash, entry.size]));
|
|
143
|
+
const pending = new Set(Array.isArray(session.pending) ? session.pending : []);
|
|
144
|
+
const accepted = [];
|
|
145
|
+
for (const part of parts) {
|
|
146
|
+
if (!pending.has(part.name))
|
|
147
|
+
return failure(400, 1004, `Asset "${part.name}" is not part of this upload session`);
|
|
148
|
+
let size;
|
|
149
|
+
if (base64) {
|
|
150
|
+
const compact = part.data.replace(/\s+/g, '');
|
|
151
|
+
if (!/^[A-Za-z0-9+/]*={0,2}$/.test(compact) || compact.length % 4 !== 0)
|
|
152
|
+
return failure(400, 1004, `Asset "${part.name}" is not valid base64`);
|
|
153
|
+
size = Buffer.from(compact, 'base64').length;
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
size = Buffer.byteLength(part.data, 'utf8');
|
|
157
|
+
}
|
|
158
|
+
if (size !== sizeOf.get(part.name)) {
|
|
159
|
+
return failure(400, 1004, `Asset "${part.name}" is ${size} bytes but the session manifest declares ${sizeOf.get(part.name)}`);
|
|
160
|
+
}
|
|
161
|
+
// wrangler sends "application/null" to mean "serve with no Content-Type".
|
|
162
|
+
const contentType = part.contentType && part.contentType !== 'application/null' ? part.contentType : null;
|
|
163
|
+
accepted.push({ hash: part.name, size, contentType });
|
|
164
|
+
}
|
|
165
|
+
const at = req.occurredAt ?? new Date().toISOString();
|
|
166
|
+
for (const asset of accepted) {
|
|
167
|
+
if (row('worker_asset', assetID(accountID, asset.hash), req.root))
|
|
168
|
+
continue; // content-addressed: already held
|
|
169
|
+
await write('worker_asset', assetID(accountID, asset.hash), 'worker_asset.upload', {
|
|
170
|
+
account_id: accountID, hash: asset.hash, size: asset.size, content_type: asset.contentType, uploaded_on: at, deleted: false,
|
|
171
|
+
}, req);
|
|
172
|
+
}
|
|
173
|
+
return success(sessionComplete(session, req.root) ? { jwt: completionToken(session, req) } : {});
|
|
174
|
+
}
|
|
175
|
+
/** Redeem a script upload's `metadata.assets.jwt`: a completion token this twin issued for this
|
|
176
|
+
* account and Worker, whose session's every hash is held. */
|
|
177
|
+
export function redeemAssetsCompletion(req, accountID, scriptName, jwt) {
|
|
178
|
+
if (typeof jwt !== 'string' || !jwt)
|
|
179
|
+
return failure(400, 1004, 'metadata.assets.jwt is required');
|
|
180
|
+
const claims = readToken(jwt);
|
|
181
|
+
if (!claims || claims.purpose !== 'completion' || claims.account_id !== accountID) {
|
|
182
|
+
return failure(400, 1004, 'metadata.assets.jwt is not a completion token for this account');
|
|
183
|
+
}
|
|
184
|
+
if (claims.script_name !== scriptName)
|
|
185
|
+
return failure(400, 1004, `metadata.assets.jwt was issued for Worker "${claims.script_name}", not "${scriptName}"`);
|
|
186
|
+
if (claims.exp < epochSeconds(req))
|
|
187
|
+
return failure(400, 1004, 'metadata.assets.jwt has expired; start a new asset upload session');
|
|
188
|
+
const session = row('worker_asset_upload_session', claims.session_id, req.root);
|
|
189
|
+
if (!session || !sessionComplete(session, req.root))
|
|
190
|
+
return failure(400, 1004, 'metadata.assets.jwt names an asset upload session whose assets are not all uploaded');
|
|
191
|
+
return { session_id: session.id, manifest: session.manifest };
|
|
192
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { handleCloudflareTwinRequest, cloudflareTwinSnapshot, CLOUDFLARE_RESOURCE_TYPES, CLOUDFLARE_ROUTER_SURFACE, cloudflareConfigurationID, } from './cloudflare-twin.js';
|
|
2
|
+
export type { CloudflareRequest, CloudflareResponse, CloudflareResourceType } from './cloudflare-twin.js';
|
|
3
|
+
export { createCloudflareTwinFetch, createCloudflareTwinServer, type CloudflareTwinFetchOptions } from './cloudflare-server.js';
|
|
4
|
+
export { liveCloudflareExecute, mapCloudflareAccount, mapCloudflareZone, mapCloudflareTunnel, mapCloudflareConfiguration, mapCloudflareDnsRecord, pullCloudflareState, syncCloudflareFromReal, cloudflareRequestForAction, performCloudflareAction, cloudflareExecuteOver, syncCloudflareFromRemote, } from './cloudflare-connector.js';
|
|
5
|
+
export type { CloudflareExecute, LiveCloudflareOptions } from './cloudflare-connector.js';
|
|
6
|
+
export { CLOUDFLARE_BUDGET_CEILING, CLOUDFLARE_BUDGET_MAX_RETRY_AFTER_S, CLOUDFLARE_BUDGET_WINDOW_MS, CLOUDFLARE_CALL_WEIGHTS, CLOUDFLARE_RATE_BUDGET, CloudflareBudget, CloudflareBudgetError, cloudflareBudgetPath, cloudflareCallWeight, } from './cloudflare-budget.js';
|
|
7
|
+
export type { CloudflareBudgetErrorKind, CloudflareBudgetOptions, CloudflareBudgetReservation, CloudflareBudgetSnapshot, } from './cloudflare-budget.js';
|
|
8
|
+
import type { TwinPack } from '@volter/world-core';
|
|
9
|
+
export declare const pack: TwinPack;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export { handleCloudflareTwinRequest, cloudflareTwinSnapshot, CLOUDFLARE_RESOURCE_TYPES, CLOUDFLARE_ROUTER_SURFACE, cloudflareConfigurationID, } from "./cloudflare-twin.js";
|
|
2
|
+
export { createCloudflareTwinFetch, createCloudflareTwinServer } from "./cloudflare-server.js";
|
|
3
|
+
export { liveCloudflareExecute, mapCloudflareAccount, mapCloudflareZone, mapCloudflareTunnel, mapCloudflareConfiguration, mapCloudflareDnsRecord, pullCloudflareState, syncCloudflareFromReal, cloudflareRequestForAction, performCloudflareAction, cloudflareExecuteOver, syncCloudflareFromRemote, } from "./cloudflare-connector.js";
|
|
4
|
+
export { CLOUDFLARE_BUDGET_CEILING, CLOUDFLARE_BUDGET_MAX_RETRY_AFTER_S, CLOUDFLARE_BUDGET_WINDOW_MS, CLOUDFLARE_CALL_WEIGHTS, CLOUDFLARE_RATE_BUDGET, CloudflareBudget, CloudflareBudgetError, cloudflareBudgetPath, cloudflareCallWeight, } from "./cloudflare-budget.js";
|
|
5
|
+
import { hosts as r2Hosts } from "../r2/src/manifest.js";
|
|
6
|
+
import { CLOUDFLARE_RATE_BUDGET } from "./cloudflare-budget.js";
|
|
7
|
+
import { registerPack, referenceField } from '@volter/world-core';
|
|
8
|
+
import { performCloudflareAction as perform, syncCloudflareFromRemote as refresh } from "./cloudflare-connector.js";
|
|
9
|
+
export const pack = {
|
|
10
|
+
vendor: 'cloudflare',
|
|
11
|
+
// PROTOCOL 2 (docs/contributing/architecture.md#protocol-2-the-pack-is-a-plugin): the pack is a plugin — its wire, its tree, and its half of the real state
|
|
12
|
+
// system: perform one entry against Cloudflare's API v4, refresh the root from it. Moved 2026-09-08.
|
|
13
|
+
protocol: '2',
|
|
14
|
+
refresh: { every: '5m', onDemand: { atMost: '30s' } }, // the v4 control plane has no webhooks: polled, or on demand
|
|
15
|
+
stateSystem: { perform, refresh },
|
|
16
|
+
// the round trip: an account and a zone the twin is told about (its bootstrap door — Cloudflare's own accounts
|
|
17
|
+
// are never created through the API), then a TXT record in that zone. TXT is the repeatable one: A/AAAA/CNAME
|
|
18
|
+
// and NS refuse a second record at the same name, exactly as Cloudflare does, so they could not run twice.
|
|
19
|
+
roundTrip: [
|
|
20
|
+
{ method: 'POST', path: '/twin/bootstrap', body: { accounts: [{ id: 'a0000000000000000000000000000001', name: 'round trip' }], zones: [{ id: 'b0000000000000000000000000000001', name: 'round.trip.test', account_id: 'a0000000000000000000000000000001' }] }, headers: { authorization: 'Bearer round-trip' } },
|
|
21
|
+
{ method: 'POST', path: '/zones/b0000000000000000000000000000001/dns_records', body: { type: 'TXT', name: 'round.trip.test', content: 'round trip' }, headers: { authorization: 'Bearer round-trip' } },
|
|
22
|
+
],
|
|
23
|
+
// rule 5: a record names its zone, a zone and a tunnel name their account, a configuration names its tunnel —
|
|
24
|
+
// when the head adopts Cloudflare's id for the parent, the kernel resolves the child's field first
|
|
25
|
+
referenceTrip: { method: 'POST', path: '/zones/{{id}}/dns_records', body: { type: 'TXT', name: 'round.trip.test', content: 'a second record on the round trip' }, headers: { authorization: 'Bearer round-trip' } },
|
|
26
|
+
references: [
|
|
27
|
+
referenceField('dns_record', 'zone_id', 'zone'),
|
|
28
|
+
referenceField('zone', 'account_id', 'account'),
|
|
29
|
+
referenceField('cloudflare_tunnel', 'account_id', 'account'),
|
|
30
|
+
referenceField('tunnel_configuration', 'tunnel_id', 'cloudflare_tunnel'),
|
|
31
|
+
],
|
|
32
|
+
parityOrigin: 'http://twin',
|
|
33
|
+
// the twin refreshed from its own wire stores what its write handler stored, key for key (gate: zero diffs)
|
|
34
|
+
shapeParity: 'held',
|
|
35
|
+
transport: 'rest',
|
|
36
|
+
archetype: 'crud',
|
|
37
|
+
bin: 'world-cloudflare',
|
|
38
|
+
resources: [
|
|
39
|
+
'account', 'zone', 'cloudflare_tunnel', 'tunnel_configuration', 'dns_record',
|
|
40
|
+
'worker_script', 'worker_route', 'worker_domain', 'workers_subdomain', 'durable_object_namespace',
|
|
41
|
+
'r2_bucket', 'r2_custom_domain', 'hyperdrive_config', 'queue', 'queue_consumer', 'worker_version', 'worker_asset', 'worker_asset_upload_session',
|
|
42
|
+
],
|
|
43
|
+
specSource: 'Cloudflare official api-schemas OpenAPI 4.0.0 at 2ac8369e9b63dccacee1a2284e95bb819f05b307 and official cloudflare-typescript SDK at faaaf89ed8064a9fb54de538ec3e89487f1302b0 (npm cloudflare@7.1.0), read 2026-08-21; provisioning slice re-read from the same pinned sources 2026-08-23.',
|
|
44
|
+
description: 'Cloudflare API v4 control-plane twin for account and zone discovery, remotely-managed Cloudflare Tunnel lifecycle/configuration/token retrieval, zone DNS-record CRUD, and the Workers deploy plane: Worker script upload (recorded + fingerprinted, never executed), cron triggers, workers.dev subdomains, zone routes, custom domains, Durable Object namespace records, R2 bucket control plane, Hyperdrive configs, and Queues with consumers. No tunnel execution, edge routing, TLS, DNS resolution, Worker execution, object storage, or origin dialing. Kernel-backed.',
|
|
45
|
+
// Adoption + interception, moved off the central maps unchanged (descriptor-first back-migration, adding-a-twin.md §3,
|
|
46
|
+
// 2026-08-31). The API v4 control plane only: the website/dashboard and customer
|
|
47
|
+
// origins are deliberately unclaimed — this pack models api.cloudflare.com/client/v4.
|
|
48
|
+
adoption: {
|
|
49
|
+
// Cloudflare's official Python library - same name as the npm client.
|
|
50
|
+
pypi: ['cloudflare'], tools: [{ package: 'wrangler', usage: 'deployment' }],
|
|
51
|
+
// aws4fetch, a SigV4 signer over fetch, is the pack's by its use: Cloudflare documents it as an R2 client
|
|
52
|
+
// (https://developers.cloudflare.com/r2/examples/aws/aws4fetch/), and the one app measured with it, Dub, stores in
|
|
53
|
+
// R2 through it (apps/web/lib/storage.ts); an app signing AWS's S3 with it is declared at init
|
|
54
|
+
sdks: ['cloudflare', 'aws4fetch'], envStems: ['CLOUDFLARE'],
|
|
55
|
+
},
|
|
56
|
+
// the API v4 control plane; the dashboard's sign-in, R2 API tokens and bucket settings (screens/dashboard.tsx); and R2's
|
|
57
|
+
// S3-compatible endpoint, `<ACCOUNT_ID>.r2.cloudflarestorage.com` and its virtual-hosted form (the R2 lane, ../r2/). A
|
|
58
|
+
// custom domain an R2 bucket is connected to is the operator's to claim for a World.
|
|
59
|
+
hosts: [{ host: 'api.cloudflare.com' }, { host: 'dash.cloudflare.com', pathPattern: '^/(login|_twin/users/|[0-9a-f]{32}/r2/|$)' }, ...r2Hosts],
|
|
60
|
+
// a custom domain connected to an R2 bucket resolves to Cloudflare: connecting it will "add the new record to the DNS
|
|
61
|
+
// table", and removing it "also removes the CNAME record" (https://developers.cloudflare.com/r2/buckets/public-buckets/,
|
|
62
|
+
// Custom domains), so the World
|
|
63
|
+
// routes each the twin has connected (cloudflare-server.ts, GET /twin/hosts)
|
|
64
|
+
hostsClaimed: { door: '/twin/hosts', note: 'the custom domains connected to R2 buckets' },
|
|
65
|
+
browserRouting: { apiPathPrefix: '/client/v4/', loaderHost: 'https://api.cloudflare.com' },
|
|
66
|
+
rateBudget: CLOUDFLARE_RATE_BUDGET,
|
|
67
|
+
};
|
|
68
|
+
// registered at import: the kernel learns the pack's state system and its references (protocol 2)
|
|
69
|
+
registerPack(pack);
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export declare const DASHBOARD_HOST = "dash.cloudflare.com";
|
|
2
|
+
/** The dashboard's doors at dash.cloudflare.com, and the World's door there; undefined for any other request. */
|
|
3
|
+
export declare function dashboardDoors(request: Request, root: string | undefined, occurredAt: string): Promise<Response | undefined>;
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
// CLOUDFLARE'S DASHBOARD DOORS — the sign-in, R2's API tokens page and a bucket's custom domains, at dash.cloudflare.com
|
|
3
|
+
// (docs/contributing/architecture.md, "Screens"). R2's S3 credentials can only be made on the R2 API tokens page ("Under
|
|
4
|
+
// the Account Details section, select Manage next to API Tokens", https://developers.cloudflare.com/r2/api/tokens/), and a
|
|
5
|
+
// bucket is connected to a custom domain on its settings page ("Select Settings. Under Custom Domains, select Add. Enter
|
|
6
|
+
// the domain name you want to connect to and select Continue.", https://developers.cloudflare.com/r2/buckets/public-buckets/).
|
|
7
|
+
// A page asks who is signed in first.
|
|
8
|
+
//
|
|
9
|
+
// THE SIGN-IN is Cloudflare's email and password one ("Email and password: Enter your email address and password.",
|
|
10
|
+
// https://developers.cloudflare.com/fundamentals/user-profiles/login/); a session cookie names the person to every page
|
|
11
|
+
// after. A person reaches an account's pages as a member of it (the pack's `account_member` rows).
|
|
12
|
+
//
|
|
13
|
+
// THE TOKENS PAGE lists the account's tokens and makes one as the page describes: "Create Account API token" or "Create
|
|
14
|
+
// User API token" ("Only users with the Super Administrator role can view or create" Account API tokens), a permission of
|
|
15
|
+
// the four, and for the two object permissions a set of buckets. Create shows the token's value, its Access Key ID and its
|
|
16
|
+
// Secret Access Key once ("You will not be able to access your Secret Access Key again after this step"). Each token's row
|
|
17
|
+
// can revoke it; a revoked token no longer signs anything for the S3 API (cloudflare-r2.ts).
|
|
18
|
+
//
|
|
19
|
+
// THE BUCKET'S SETTINGS list its custom domains; Add connects a domain of one of the account's zones, which the control
|
|
20
|
+
// plane's attach operation does (cloudflare-provisioning.ts), and Remove disconnects it.
|
|
21
|
+
//
|
|
22
|
+
// Where Cloudflare's documentation stops and the twin decides: a person is what the World gives them
|
|
23
|
+
// (`POST /_twin/users/:email` {password}, standing in for Cloudflare's sign-up, as vercel's door does); the session cookie is
|
|
24
|
+
// named `cf_twin_session`; the pages' paths (`/{account}/r2/overview`, `/{account}/r2/api-tokens`,
|
|
25
|
+
// `/{account}/r2/default/buckets/{bucket}/settings`) follow the dashboard links the docs give (`?to=/:account/r2/overview`);
|
|
26
|
+
// a token's buckets are typed as names separated by commas, where Cloudflare's form offers a list to pick from; tokens have
|
|
27
|
+
// no TTL (Cloudflare's form offers one); connecting a domain skips the review of the DNS record it adds, and adds none;
|
|
28
|
+
// a page's address answers 405 to any method but GET (Cloudflare documents no answer for one).
|
|
29
|
+
// Built from @volter/world-ui's pieces under Cloudflare's orange-and-white skin; nothing of Cloudflare's page is copied.
|
|
30
|
+
import { createHash } from 'node:crypto';
|
|
31
|
+
import { applyTwinWrite, projectResources } from '@volter/world-core';
|
|
32
|
+
import { deterministicHex } from "../cloudflare-common.js";
|
|
33
|
+
import { cookieOf, flowPage, Portal, PORTAL_CSS, SignIn, SIGN_IN_CSS } from '@volter/world-ui';
|
|
34
|
+
import { catchUpR2CustomDomains, connectR2CustomDomain, createR2Token, R2_PERMISSIONS, r2CustomDomains, r2Tokens, removeR2CustomDomain, revokeR2Token, } from "../cloudflare-r2.js";
|
|
35
|
+
export const DASHBOARD_HOST = 'dash.cloudflare.com';
|
|
36
|
+
const SERVICE = 'cloudflare';
|
|
37
|
+
const COOKIE = 'cf_twin_session';
|
|
38
|
+
const SUPER_ADMIN = 'Super Administrator - All Privileges';
|
|
39
|
+
const ADMINS = new Set([SUPER_ADMIN, 'Administrator']);
|
|
40
|
+
const SKIN = `
|
|
41
|
+
body { background: #ffffff; color: #1d1f20; }
|
|
42
|
+
.sign-in-mark { background: #f38020; border-radius: 50%; }
|
|
43
|
+
.sign-in-error { background: #fdecea; border-color: #f5c2c0; color: #1d1f20; }
|
|
44
|
+
.sign-in-box { background: #ffffff; border-color: #d9d9d9; }
|
|
45
|
+
.sign-in-submit { background: #0051c3; border-color: #0051c3; color: #ffffff; }
|
|
46
|
+
.portal-side { background: #f2f2f2; border-right: 1px solid #d9d9d9; }
|
|
47
|
+
.portal-notice { background: #fff7e6; border: 1px solid #f38020; font-family: ui-monospace, monospace; word-break: break-all; }
|
|
48
|
+
.portal-primary { background: #0051c3; border-color: #0051c3; color: #ffffff; }
|
|
49
|
+
`;
|
|
50
|
+
const hex = (s) => createHash('sha256').update(s).digest('hex');
|
|
51
|
+
const rowsOf = (type, root) => projectResources(SERVICE, root).filter((r) => r.type === type && r.deleted !== true);
|
|
52
|
+
const record = (type, id, fields, root, occurredAt) => applyTwinWrite(SERVICE, { operation: `${type.slice(1)}.write`, subjectType: type, subjectId: id, fields, occurredAt, actor: { kind: 'agent' } }, root);
|
|
53
|
+
async function formOf(request) {
|
|
54
|
+
return Object.fromEntries(new URLSearchParams(await request.text()));
|
|
55
|
+
}
|
|
56
|
+
const nextOf = (raw) => (raw && raw.startsWith('/') && !raw.startsWith('//') ? raw : '/');
|
|
57
|
+
const redirect = (location) => new Response(null, { status: 302, headers: { location } });
|
|
58
|
+
const toLogin = (path) => redirect(`/login?next=${encodeURIComponent(path)}`);
|
|
59
|
+
function signInPage(next, error, status = 200) {
|
|
60
|
+
return flowPage({
|
|
61
|
+
title: 'Log in | Cloudflare',
|
|
62
|
+
css: [SIGN_IN_CSS, SKIN],
|
|
63
|
+
status,
|
|
64
|
+
body: (_jsx(SignIn, { heading: "Log in to Cloudflare", action: "/login", fields: { next }, account: { name: 'email', label: 'Email' }, password: { name: 'password', label: 'Password' }, submit: "Log in", ...(error ? { error } : {}) })),
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
/** The person the request's session cookie names, or undefined when nobody is signed in. */
|
|
68
|
+
function signedIn(request, root) {
|
|
69
|
+
const token = cookieOf(request, COOKIE);
|
|
70
|
+
const session = token ? rowsOf('_web_session', root).find((s) => s.token === token) : undefined;
|
|
71
|
+
return session ? rowsOf('_person', root).find((p) => p.email === session.email) : undefined;
|
|
72
|
+
}
|
|
73
|
+
/** The person's roles in an account, by name; none when they are not an accepted member. */
|
|
74
|
+
function rolesIn(person, accountId, root) {
|
|
75
|
+
const member = rowsOf('account_member', root).find((m) => m.account_id === accountId && m.email === person.email && m.status !== 'pending');
|
|
76
|
+
if (!member)
|
|
77
|
+
return [];
|
|
78
|
+
return (member.role_ids ?? []).map((id) => ROLE_NAMES.find((n) => deterministicHex(`cf-role:${n}`, 32) === id) ?? id);
|
|
79
|
+
}
|
|
80
|
+
// the account's built-in roles (cloudflare-twin.ts ROLES), whose ids the pack derives from their names
|
|
81
|
+
const ROLE_NAMES = [SUPER_ADMIN, 'Administrator', 'Administrator Read Only', 'Workers Admin'];
|
|
82
|
+
function accountName(accountId, root) {
|
|
83
|
+
return String(rowsOf('account', root).find((a) => a.id === accountId)?.name ?? accountId);
|
|
84
|
+
}
|
|
85
|
+
function homePage(person, root) {
|
|
86
|
+
const accounts = rowsOf('account_member', root).filter((m) => m.email === person.email && m.status !== 'pending').map((m) => String(m.account_id));
|
|
87
|
+
return flowPage({
|
|
88
|
+
title: 'Account Home | Cloudflare',
|
|
89
|
+
css: [PORTAL_CSS, SKIN],
|
|
90
|
+
body: (_jsx(Portal, { merchant: String(person.email), sections: [{ heading: 'Accounts', empty: 'You are not a member of any account.', items: accounts.map((id) => ({ title: accountName(id, root), detail: id, actions: [{ label: 'R2 Object Storage', action: `/${id}/r2/overview`, method: 'get' }] })) }] })),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
function overviewPage(person, accountId, root) {
|
|
94
|
+
const buckets = rowsOf('r2_bucket', root).filter((b) => b.account_id === accountId).sort((a, b) => String(a.name).localeCompare(String(b.name)));
|
|
95
|
+
return flowPage({
|
|
96
|
+
title: 'R2 Object Storage | Cloudflare',
|
|
97
|
+
css: [PORTAL_CSS, SKIN],
|
|
98
|
+
body: (_jsx(Portal, { merchant: `${accountName(accountId, root)} · R2 Object Storage`, back: { href: `/${accountId}/r2/api-tokens`, label: 'Manage API Tokens' }, sections: [{ heading: 'Buckets', empty: 'No buckets yet.', items: buckets.map((b) => ({ title: String(b.name), detail: `Created ${String(b.creation_date).slice(0, 10)}`, actions: [{ label: 'Settings', action: `/${accountId}/r2/default/buckets/${String(b.name)}/settings`, method: 'get' }] })) }] })),
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
function tokensPage(person, accountId, root, notice, status = 200) {
|
|
102
|
+
const superAdmin = rolesIn(person, accountId, root).includes(SUPER_ADMIN);
|
|
103
|
+
const visible = r2Tokens(accountId, root).filter((t) => (t.kind === 'account' ? superAdmin : t.owner === person.email));
|
|
104
|
+
const label = (p) => R2_PERMISSIONS.find((x) => x.value === p)?.label ?? p;
|
|
105
|
+
const fields = [
|
|
106
|
+
{ id: 'name', label: 'Token name' },
|
|
107
|
+
{ id: 'permission', label: 'Permissions', options: R2_PERMISSIONS.map((p) => ({ value: p.value, label: p.label })) },
|
|
108
|
+
{ id: 'buckets', label: 'Specify bucket(s)', placeholder: 'All buckets in this account (or bucket names, separated by commas)' },
|
|
109
|
+
];
|
|
110
|
+
return flowPage({
|
|
111
|
+
title: 'R2 API Tokens | Cloudflare',
|
|
112
|
+
css: [PORTAL_CSS, SKIN],
|
|
113
|
+
status,
|
|
114
|
+
body: (_jsx(Portal, { merchant: `${accountName(accountId, root)} · R2 API Tokens`, back: { href: `/${accountId}/r2/overview`, label: 'R2 Object Storage' }, ...(notice ? { notice } : {}), sections: [{
|
|
115
|
+
heading: 'API Tokens',
|
|
116
|
+
empty: 'No API tokens yet.',
|
|
117
|
+
items: visible.map((t) => ({
|
|
118
|
+
title: String(t.name),
|
|
119
|
+
badge: t.kind === 'account' ? 'Account' : 'User',
|
|
120
|
+
detail: `${label(t.permission)} · ${t.buckets === '*' ? 'All buckets' : t.buckets.join(', ')}`,
|
|
121
|
+
note: `Access Key ID ${t.access_key_id}`,
|
|
122
|
+
actions: [{ label: 'Revoke', action: `/${accountId}/r2/api-tokens/revoke`, fields: { token: t.id }, tone: 'danger' }],
|
|
123
|
+
})),
|
|
124
|
+
}], forms: [
|
|
125
|
+
...(superAdmin ? [{ heading: 'Create Account API token', action: `/${accountId}/r2/api-tokens?kind=account`, fields, submit: { label: 'Create Account API Token' } }] : []),
|
|
126
|
+
{ heading: 'Create User API token', action: `/${accountId}/r2/api-tokens?kind=user`, fields, submit: { label: 'Create User API Token' } },
|
|
127
|
+
] })),
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
function bucketSettingsPage(person, accountId, bucket, root, notice, status = 200) {
|
|
131
|
+
const domains = r2CustomDomains(root).filter((d) => d.account_id === accountId && d.bucket === bucket);
|
|
132
|
+
const base = `/${accountId}/r2/default/buckets/${bucket}/settings`;
|
|
133
|
+
return flowPage({
|
|
134
|
+
title: `${bucket} Settings | R2 | Cloudflare`,
|
|
135
|
+
css: [PORTAL_CSS, SKIN],
|
|
136
|
+
status,
|
|
137
|
+
body: (_jsx(Portal, { merchant: `${accountName(accountId, root)} · ${bucket}`, back: { href: `/${accountId}/r2/overview`, label: 'R2 Object Storage' }, ...(notice ? { notice } : {}), sections: [{
|
|
138
|
+
heading: 'Custom Domains',
|
|
139
|
+
empty: 'No custom domains connected.',
|
|
140
|
+
items: domains.map((d) => ({
|
|
141
|
+
title: d.domain,
|
|
142
|
+
badge: d.ownership === 'active' ? 'Active' : 'Initializing',
|
|
143
|
+
detail: `Access to Bucket: ${d.enabled ? 'Allowed' : 'Not allowed'}`,
|
|
144
|
+
note: `Minimum TLS ${d.min_tls}`,
|
|
145
|
+
actions: [{ label: 'Remove domain', action: `${base}/custom-domains/remove`, fields: { domain: d.domain }, tone: 'danger' }],
|
|
146
|
+
})),
|
|
147
|
+
}], forms: [{ heading: 'Add Custom Domain', action: `${base}/custom-domains`, fields: [{ id: 'domain', label: 'Domain' }], submit: { label: 'Connect Domain' } }] })),
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
/** The dashboard's doors at dash.cloudflare.com, and the World's door there; undefined for any other request. */
|
|
151
|
+
export async function dashboardDoors(request, root, occurredAt) {
|
|
152
|
+
const url = new URL(request.url);
|
|
153
|
+
const path = url.pathname.replace(/\/+$/, '') || '/';
|
|
154
|
+
await catchUpR2CustomDomains(root, occurredAt);
|
|
155
|
+
// POST /_twin/users/:email {password}: the World's stand-in for Cloudflare's sign-up of a person; the accounts they
|
|
156
|
+
// belong to are the account's members (the pack's bootstrap door)
|
|
157
|
+
const signup = /^\/_twin\/users\/([^/]+)$/.exec(path);
|
|
158
|
+
if (signup && request.method === 'POST') {
|
|
159
|
+
const email = decodeURIComponent(signup[1]).toLowerCase();
|
|
160
|
+
let body = {};
|
|
161
|
+
try {
|
|
162
|
+
body = JSON.parse(await request.text());
|
|
163
|
+
}
|
|
164
|
+
catch { /* refused below */ }
|
|
165
|
+
if (!/^[^@\s]+@[^@\s]+$/.test(email) || typeof body.password !== 'string' || !body.password)
|
|
166
|
+
return Response.json({ error: 'an email and a password are required' }, { status: 400 });
|
|
167
|
+
await record('_person', email, { email, password_sha256: hex(body.password) }, root, occurredAt);
|
|
168
|
+
return Response.json({ email }, { status: 201 });
|
|
169
|
+
}
|
|
170
|
+
if (path === '/login' && request.method === 'GET')
|
|
171
|
+
return signInPage(nextOf(url.searchParams.get('next')));
|
|
172
|
+
if (path === '/login' && request.method === 'POST') {
|
|
173
|
+
const f = await formOf(request);
|
|
174
|
+
const email = (f.email ?? '').trim().toLowerCase();
|
|
175
|
+
const next = nextOf(f.next);
|
|
176
|
+
const person = rowsOf('_person', root).find((p) => p.email === email);
|
|
177
|
+
if (!person || person.password_sha256 !== hex(f.password ?? ''))
|
|
178
|
+
return signInPage(next, 'Incorrect email address or password', 401);
|
|
179
|
+
const token = hex(`session:${email}:${occurredAt}:${rowsOf('_web_session', root).length + 1}`).slice(0, 48);
|
|
180
|
+
await record('_web_session', token, { token, email }, root, occurredAt);
|
|
181
|
+
const headers = new Headers({ location: next });
|
|
182
|
+
headers.append('set-cookie', `${COOKIE}=${token}; path=/; HttpOnly; Secure; SameSite=Lax`);
|
|
183
|
+
return new Response(null, { status: 302, headers });
|
|
184
|
+
}
|
|
185
|
+
const person = signedIn(request, root);
|
|
186
|
+
if (path === '/')
|
|
187
|
+
return person ? homePage(person, root) : toLogin('/');
|
|
188
|
+
const page = /^\/([0-9a-f]{32})\/r2\/(overview|api-tokens(?:\/revoke)?|default\/buckets\/([^/]+)\/settings(?:\/custom-domains(?:\/remove)?)?)$/.exec(path);
|
|
189
|
+
if (!page)
|
|
190
|
+
return undefined;
|
|
191
|
+
if (!person)
|
|
192
|
+
return toLogin(path);
|
|
193
|
+
const accountId = page[1];
|
|
194
|
+
const roles = rolesIn(person, accountId, root);
|
|
195
|
+
if (!roles.length)
|
|
196
|
+
return flowPage({ title: 'Access denied | Cloudflare', css: [PORTAL_CSS, SKIN], status: 403, body: _jsx(Portal, { merchant: String(person.email), back: { href: '/', label: 'Account Home' }, notice: "You do not have access to this account.", sections: [] }) });
|
|
197
|
+
const where = page[2];
|
|
198
|
+
// a page is opened with GET. The twin's own answer, which Cloudflare does not document: any other method on a page's
|
|
199
|
+
// address answers 405 Method Not Allowed
|
|
200
|
+
const notAllowed = () => new Response('Method Not Allowed', { status: 405, headers: { 'content-type': 'text/plain', allow: 'GET' } });
|
|
201
|
+
if (where === 'overview')
|
|
202
|
+
return request.method === 'GET' ? overviewPage(person, accountId, root) : notAllowed();
|
|
203
|
+
if (where === 'api-tokens' && request.method === 'GET')
|
|
204
|
+
return tokensPage(person, accountId, root);
|
|
205
|
+
if (where === 'api-tokens' && request.method === 'POST') {
|
|
206
|
+
const f = await formOf(request);
|
|
207
|
+
const kind = url.searchParams.get('kind') === 'account' ? 'account' : 'user';
|
|
208
|
+
if (kind === 'account' && !roles.includes(SUPER_ADMIN))
|
|
209
|
+
return tokensPage(person, accountId, root, 'Only users with the Super Administrator role can create Account API tokens.', 403);
|
|
210
|
+
const name = (f.name ?? '').trim();
|
|
211
|
+
if (!name)
|
|
212
|
+
return tokensPage(person, accountId, root, 'A token needs a name.', 422);
|
|
213
|
+
const permission = R2_PERMISSIONS.find((p) => p.value === f.permission)?.value;
|
|
214
|
+
if (!permission)
|
|
215
|
+
return tokensPage(person, accountId, root, 'Choose a permission.', 422);
|
|
216
|
+
const names = (f.buckets ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
217
|
+
const known = new Set(rowsOf('r2_bucket', root).filter((b) => b.account_id === accountId).map((b) => String(b.name)));
|
|
218
|
+
const unknown = names.filter((n) => !known.has(n));
|
|
219
|
+
if (unknown.length)
|
|
220
|
+
return tokensPage(person, accountId, root, `No bucket named ${unknown.join(', ')} in this account.`, 422);
|
|
221
|
+
// only the object permissions are scoped to buckets ("If you select the Object Read and Write or Object Read
|
|
222
|
+
// permissions, you can scope your token to a set of buckets")
|
|
223
|
+
const buckets = permission.startsWith('object') && names.length ? names : '*';
|
|
224
|
+
const { token, value } = await createR2Token({ accountId, name, kind, owner: String(person.email), permission, buckets }, root, occurredAt);
|
|
225
|
+
return tokensPage(person, accountId, root, `Token ${name} created. Token value: ${value} · Access Key ID: ${token.access_key_id} · Secret Access Key: ${token.secret_access_key} — copy these now: you will not be able to see the Secret Access Key again.`);
|
|
226
|
+
}
|
|
227
|
+
if (where === 'api-tokens/revoke' && request.method === 'POST') {
|
|
228
|
+
const f = await formOf(request);
|
|
229
|
+
const token = r2Tokens(accountId, root).find((t) => t.id === f.token && (t.kind === 'account' ? roles.includes(SUPER_ADMIN) : t.owner === person.email));
|
|
230
|
+
if (!token)
|
|
231
|
+
return tokensPage(person, accountId, root, 'Choose a token you can revoke.', 422);
|
|
232
|
+
await revokeR2Token(token.id, root, occurredAt);
|
|
233
|
+
return tokensPage(person, accountId, root, `Token ${token.name} revoked.`);
|
|
234
|
+
}
|
|
235
|
+
const bucket = page[3] ? decodeURIComponent(page[3]) : undefined;
|
|
236
|
+
if (!bucket)
|
|
237
|
+
return undefined;
|
|
238
|
+
if (!rowsOf('r2_bucket', root).some((b) => b.account_id === accountId && b.name === bucket))
|
|
239
|
+
return flowPage({ title: 'Not found | Cloudflare', css: [PORTAL_CSS, SKIN], status: 404, body: _jsx(Portal, { merchant: accountName(accountId, root), back: { href: `/${accountId}/r2/overview`, label: 'R2 Object Storage' }, notice: `There is no bucket named ${bucket}.`, sections: [] }) });
|
|
240
|
+
const settings = `/${accountId}/r2/default/buckets/${bucket}/settings`;
|
|
241
|
+
if (where.endsWith('/settings'))
|
|
242
|
+
return request.method === 'GET' ? bucketSettingsPage(person, accountId, bucket, root) : notAllowed();
|
|
243
|
+
if (!roles.some((r) => ADMINS.has(r)))
|
|
244
|
+
return bucketSettingsPage(person, accountId, bucket, root, 'Your role cannot change this bucket\'s settings.', 403);
|
|
245
|
+
if (where.endsWith('/custom-domains') && request.method === 'POST') {
|
|
246
|
+
const domain = ((await formOf(request)).domain ?? '').trim().toLowerCase();
|
|
247
|
+
// "The domain being used must have been added as a zone in the same account as the R2 bucket."
|
|
248
|
+
const zone = rowsOf('zone', root).filter((z) => z.account_id === accountId).find((z) => domain === z.name || domain.endsWith(`.${String(z.name)}`));
|
|
249
|
+
if (!zone)
|
|
250
|
+
return bucketSettingsPage(person, accountId, bucket, root, `${domain || 'That domain'} is not in a zone of this account. Add the domain to Cloudflare first.`, 422);
|
|
251
|
+
if (r2CustomDomains(root).some((d) => d.domain === domain))
|
|
252
|
+
return bucketSettingsPage(person, accountId, bucket, root, `${domain} is already connected to a bucket.`, 409);
|
|
253
|
+
await connectR2CustomDomain({ accountId, bucket, domain, enabled: true, zoneId: String(zone.id), zoneName: String(zone.name) }, root, occurredAt);
|
|
254
|
+
return redirect(settings);
|
|
255
|
+
}
|
|
256
|
+
if (where.endsWith('/custom-domains/remove') && request.method === 'POST') {
|
|
257
|
+
const domain = ((await formOf(request)).domain ?? '').trim().toLowerCase();
|
|
258
|
+
const d = r2CustomDomains(root).find((x) => x.account_id === accountId && x.bucket === bucket && x.domain === domain);
|
|
259
|
+
if (!d)
|
|
260
|
+
return bucketSettingsPage(person, accountId, bucket, root, `${domain} is not connected to this bucket.`, 422);
|
|
261
|
+
await removeR2CustomDomain(d.id, root, occurredAt);
|
|
262
|
+
return redirect(settings);
|
|
263
|
+
}
|
|
264
|
+
return undefined;
|
|
265
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@volter/twin-cloudflare",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Local Cloudflare API twin for accounts, zones, Cloudflare Tunnels, tunnel configurations and zone DNS records, built on @volter/world-core.",
|
|
5
|
+
"author": "Volter (https://github.com/volter-ai)",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"files": [
|
|
8
|
+
"src",
|
|
9
|
+
"README.md",
|
|
10
|
+
"LICENSE",
|
|
11
|
+
"!**/*.test.ts",
|
|
12
|
+
"dist"
|
|
13
|
+
],
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/volter-ai/twin.git",
|
|
17
|
+
"directory": "packages/twin/cloudflare"
|
|
18
|
+
},
|
|
19
|
+
"homepage": "https://github.com/volter-ai/twin/tree/main/packages/twin/cloudflare#readme",
|
|
20
|
+
"type": "module",
|
|
21
|
+
"exports": {
|
|
22
|
+
".": {
|
|
23
|
+
"types": "./dist/src/index.d.ts",
|
|
24
|
+
"default": "./dist/src/index.js"
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
"bin": {
|
|
28
|
+
"world-cloudflare": "dist/src/cli.js"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"test": "bun test src/*.test.ts",
|
|
32
|
+
"typecheck": "tsc --noEmit",
|
|
33
|
+
"build": "node ../../../scripts/publish/build.mjs",
|
|
34
|
+
"prepack": "node ../../../scripts/publish/prepare-publish.mjs prepack",
|
|
35
|
+
"postpack": "node ../../../scripts/publish/prepare-publish.mjs postpack"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@volter/world-ui": "0.1.0",
|
|
39
|
+
"react": "^19.2.7",
|
|
40
|
+
"react-dom": "^19.2.7"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"@volter/world-core": "2.0.0"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"@types/bun": "^1.2.20",
|
|
47
|
+
"@types/node": "^24.0.0",
|
|
48
|
+
"@volter/world-core": "2.0.0",
|
|
49
|
+
"@volter/world-tooling": "0.1.0",
|
|
50
|
+
"cloudflare": "7.1.0",
|
|
51
|
+
"typescript": "^5.9.0"
|
|
52
|
+
},
|
|
53
|
+
"engines": {
|
|
54
|
+
"node": ">=22.3"
|
|
55
|
+
}
|
|
56
|
+
}
|