Skip to content

Commit ec412a0

Browse files
r8a5claude
andcommitted
True one-click installer at seo.benjaminb.xyz/install
There's no public OAuth-app program at Cloudflare today, but their API-token templating gets us close enough that this counts as one-click for most operators. The installer page (public/install.html, served at /install): - Deep-links to dash.cloudflare.com/?to=/:account/api-tokens/create with permissionGroupKeys pre-filled (Pages/D1/R2/Workers AI Edit + Account Settings Read + Workers Scripts Edit). - Collects token + site name + project slug + email + password. - Animates a six-step progress list while the provisioner runs. The provisioner (functions/api/install/provision.js): - Resolves the user's account id from /accounts. - Creates a D1 database named after the project slug. - Creates an R2 bucket named "<slug>-images" (tolerates 409 if the user reruns with the same slug). - Creates a Pages project bound to Benjamin-Bloch/pages-seo on GitHub with all bindings configured (DB, IMAGES, AI) and SITE_NAME / SITE_URL env vars pre-set. - Triggers the first deployment. - Returns the new pages.dev subdomain + the seed (email + password + site_name) for the client to encode into a URL hash. Installer hand-off: - The "Open my dashboard" link points at `<new-site>/admin#install=<base64-json>` carrying the seed. - The admin SPA's Setup module decodes the hash, pre-fills the first-run setup form, and submits it programmatically — the operator never retypes the password they just chose. - Server-side validation in /api/setup is unchanged. The hash never leaves the browser (fragments don't get sent in HTTP). The API token isn't stored anywhere; it lives only in the install request body and is discarded once the response returns. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 89123fd commit ec412a0

3 files changed

Lines changed: 520 additions & 1 deletion

File tree

functions/api/install/provision.js

Lines changed: 212 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,212 @@
1+
// POST /api/install/provision
2+
//
3+
// One-shot installer endpoint. Takes the user's Cloudflare API token
4+
// + install details and provisions:
5+
// 1. The user's account id (from /accounts).
6+
// 2. A D1 database named after their project slug.
7+
// 3. An R2 bucket named "<slug>-images".
8+
// 4. A Pages project bound to the GitHub repo with all three
9+
// bindings configured + the Workers AI binding.
10+
// 5. Triggers the first deployment.
11+
//
12+
// Schema application + admin-user seeding happen in the *target* site's
13+
// own /api/setup endpoint after the first deploy finishes. The
14+
// installer doesn't try to apply schema directly to D1 over the API —
15+
// D1's REST `query` endpoint only accepts single statements and would
16+
// need a separate trip per CREATE TABLE.
17+
//
18+
// The user's API token never leaves the installer's process. We do
19+
// not persist it.
20+
21+
import { json } from '../../_lib/util.js';
22+
23+
const CF_API = 'https://api.cloudflare.com/client/v4';
24+
const REPO_OWNER = 'Benjamin-Bloch';
25+
const REPO_NAME = 'pages-seo';
26+
const PROD_BRANCH = 'main';
27+
28+
function fail(at, status, detail) {
29+
return json(status, { ok: false, failed_at: at, error: 'install_failed', detail });
30+
}
31+
32+
async function cfFetch(token, path, init = {}) {
33+
const res = await fetch(CF_API + path, {
34+
...init,
35+
headers: {
36+
Authorization: 'Bearer ' + token,
37+
'Content-Type': 'application/json',
38+
...(init.headers || {}),
39+
},
40+
});
41+
let body = null;
42+
try { body = await res.json(); } catch { /* non-JSON */ }
43+
return { res, body };
44+
}
45+
46+
function firstErrorMessage(body) {
47+
if (!body) return null;
48+
if (Array.isArray(body.errors) && body.errors.length) {
49+
return body.errors.map((e) => e.message || String(e)).join(' · ');
50+
}
51+
return body.error || body.detail || null;
52+
}
53+
54+
const SLUG_RX = /^[a-z][a-z0-9-]{1,32}$/;
55+
const EMAIL_RX = /^[^@\s]+@[^@\s]+\.[^@\s]+$/;
56+
57+
export const onRequestPost = async ({ request }) => {
58+
let body;
59+
try { body = await request.json(); } catch { return json(400, { error: 'bad_json' }); }
60+
61+
const token = String(body?.token || '').trim();
62+
const project = String(body?.project || '').trim().toLowerCase();
63+
const siteName = String(body?.site_name || '').trim();
64+
const email = String(body?.email || '').trim().toLowerCase();
65+
const password = String(body?.password || '');
66+
67+
if (!token) return fail('validate', 400, 'API token required');
68+
if (!SLUG_RX.test(project)) return fail('validate', 400, 'Project slug: lowercase letters/digits/dashes, 2–33 chars, must start with a letter');
69+
if (!siteName) return fail('validate', 400, 'Site name required');
70+
if (!EMAIL_RX.test(email)) return fail('validate', 400, 'Valid email required');
71+
if (password.length < 12) return fail('validate', 400, 'Password must be 12+ characters');
72+
73+
// ── 1. account ──────────────────────────────────────────────
74+
const accR = await cfFetch(token, '/accounts');
75+
if (!accR.res.ok || !accR.body?.result?.length) {
76+
return fail('account', 401, firstErrorMessage(accR.body) || 'Token rejected by Cloudflare (check scopes).');
77+
}
78+
const accountId = accR.body.result[0].id;
79+
const accountName = accR.body.result[0].name;
80+
81+
// ── 2. D1 database ──────────────────────────────────────────
82+
const d1Name = project;
83+
const d1R = await cfFetch(token, `/accounts/${accountId}/d1/database`, {
84+
method: 'POST',
85+
body: JSON.stringify({ name: d1Name }),
86+
});
87+
if (!d1R.res.ok || !d1R.body?.result?.uuid) {
88+
return fail('d1', d1R.res.status || 500, firstErrorMessage(d1R.body) || 'Failed to create D1 database (slug taken on this account?).');
89+
}
90+
const d1Id = d1R.body.result.uuid;
91+
92+
// ── 3. R2 bucket ────────────────────────────────────────────
93+
const r2Name = project + '-images';
94+
const r2R = await cfFetch(token, `/accounts/${accountId}/r2/buckets`, {
95+
method: 'POST',
96+
body: JSON.stringify({ name: r2Name }),
97+
});
98+
// R2 returns 409 if the bucket already exists on this account — we
99+
// tolerate that (the user may have run the installer before) but
100+
// not other failures.
101+
if (!r2R.res.ok && r2R.res.status !== 409) {
102+
return fail('r2', r2R.res.status || 500, firstErrorMessage(r2R.body) || 'Failed to create R2 bucket.');
103+
}
104+
105+
// ── 4. Pages project ────────────────────────────────────────
106+
// Bindings, env vars, and the GitHub source all go in this one
107+
// call. Production-only deployment configs — we don't bother with
108+
// preview branches for installs.
109+
const pagesPayload = {
110+
name: project,
111+
production_branch: PROD_BRANCH,
112+
source: {
113+
type: 'github',
114+
config: {
115+
owner: REPO_OWNER,
116+
repo_name: REPO_NAME,
117+
production_branch: PROD_BRANCH,
118+
production_deployments_enabled: true,
119+
deployments_enabled: true,
120+
},
121+
},
122+
deployment_configs: {
123+
production: {
124+
d1_databases: { DB: { id: d1Id } },
125+
r2_buckets: { IMAGES: { name: r2Name } },
126+
ai_bindings: { AI: {} },
127+
env_vars: {
128+
SITE_NAME: { type: 'plain_text', value: siteName },
129+
},
130+
},
131+
preview: {
132+
d1_databases: { DB: { id: d1Id } },
133+
r2_buckets: { IMAGES: { name: r2Name } },
134+
ai_bindings: { AI: {} },
135+
env_vars: {
136+
SITE_NAME: { type: 'plain_text', value: siteName },
137+
},
138+
},
139+
},
140+
};
141+
142+
const pagesR = await cfFetch(token, `/accounts/${accountId}/pages/projects`, {
143+
method: 'POST',
144+
body: JSON.stringify(pagesPayload),
145+
});
146+
if (!pagesR.res.ok || !pagesR.body?.result?.subdomain) {
147+
return fail('pages', pagesR.res.status || 500, firstErrorMessage(pagesR.body) || 'Failed to create Pages project. The slug may already be in use on Cloudflare Pages.');
148+
}
149+
const subdomain = pagesR.body.result.subdomain;
150+
const pagesUrl = `https://${subdomain}`;
151+
152+
// SITE_URL needs to point at the Pages domain. We set it after the
153+
// project exists because we don't know the subdomain until then.
154+
// (Cloudflare assigns one of the form `<slug>-<hash>.pages.dev` if
155+
// the bare `<slug>.pages.dev` is taken.)
156+
const envPatch = {
157+
deployment_configs: {
158+
production: {
159+
env_vars: {
160+
SITE_URL: { type: 'plain_text', value: pagesUrl },
161+
},
162+
},
163+
preview: {
164+
env_vars: {
165+
SITE_URL: { type: 'plain_text', value: pagesUrl },
166+
},
167+
},
168+
},
169+
};
170+
await cfFetch(token, `/accounts/${accountId}/pages/projects/${project}`, {
171+
method: 'PATCH',
172+
body: JSON.stringify(envPatch),
173+
});
174+
// Best-effort: if the PATCH fails, the in-app /api/setup still
175+
// works because it can derive SITE_URL from settings the user
176+
// submits there. We don't block the install on this.
177+
178+
// ── 5. Kick off the first deployment ────────────────────────
179+
// Creating a Pages project with a GitHub source enables deploys but
180+
// doesn't trigger one immediately. We POST /deployments to fire the
181+
// first build right away.
182+
await cfFetch(token, `/accounts/${accountId}/pages/projects/${project}/deployments`, {
183+
method: 'POST',
184+
});
185+
186+
// ── 6. Stash the seed payload for /api/setup on the new site ──
187+
// We can't reach the new site's /api/setup yet — it's still
188+
// building. The new install will reach our /api/install/seed-info
189+
// endpoint with its project slug on first boot to fetch the
190+
// pre-supplied email + password + SITE_NAME so the operator never
191+
// has to type them twice.
192+
//
193+
// Storage: keep it on THIS site's D1 (the installer's), encrypted
194+
// at rest with a random key embedded in the URL we hand back. We
195+
// don't have access to the new site's D1 to write directly. For
196+
// v1, we skip this convenience step — the user will type their
197+
// password once into the /admin first-run setup card after the
198+
// deploy completes. That's still a clean experience.
199+
200+
return json(200, {
201+
ok: true,
202+
pages_url: pagesUrl,
203+
account: { id: accountId, name: accountName },
204+
project,
205+
d1: { id: d1Id, name: d1Name },
206+
r2: { name: r2Name },
207+
// Pre-fill these in the first-run setup form on the new site so
208+
// the operator doesn't retype them. Sent back to the browser
209+
// (not stored).
210+
seed: { email, password, site_name: siteName },
211+
});
212+
};

public/admin.js

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2237,13 +2237,49 @@
22372237
// and creates the first user; we then log the operator in with
22382238
// their just-set password so they land in the onboarding wizard.
22392239
const Setup = (() => {
2240-
function show() {
2240+
// Decode #install=<base64-json> if the installer redirected here.
2241+
// The hash carries email + password + site_name from the
2242+
// installer at seo.benjaminb.xyz/install so the operator never
2243+
// retypes them. Hash fragments don't get sent to the server.
2244+
function readInstallSeed() {
2245+
const m = (location.hash || '').match(/(?:^#|&)install=([^&]+)/);
2246+
if (!m) return null;
2247+
try {
2248+
let b64 = m[1].replace(/-/g, '+').replace(/_/g, '/');
2249+
while (b64.length % 4) b64 += '=';
2250+
const json = decodeURIComponent(escape(atob(b64)));
2251+
const seed = JSON.parse(json);
2252+
if (seed && seed.email && seed.password) return seed;
2253+
} catch { /* malformed — fall back to manual form */ }
2254+
return null;
2255+
}
2256+
2257+
function clearHash() {
2258+
try { history.replaceState(null, '', location.pathname + location.search); }
2259+
catch { /* iframes etc. */ }
2260+
}
2261+
2262+
async function show() {
22412263
$('#gate').hidden = true;
22422264
$('#dash').hidden = true;
22432265
$('#wiz').hidden = true;
22442266
$('#setup').hidden = false;
22452267
$('#setup-form-pane').hidden = false;
22462268
$('#setup-success').hidden = true;
2269+
2270+
// Installer hand-off: pre-fill + auto-submit the setup form.
2271+
const seed = readInstallSeed();
2272+
if (seed) {
2273+
clearHash();
2274+
$('#setup-site-name').value = seed.site_name || '';
2275+
$('#setup-site-url').value = location.origin;
2276+
$('#setup-email').value = seed.email || '';
2277+
$('#setup-password').value = seed.password || '';
2278+
// Fire the same submit path so server validation + auto-login
2279+
// happen exactly as if the operator typed it.
2280+
$('#setup-form').dispatchEvent(new Event('submit', { cancelable: true }));
2281+
return;
2282+
}
22472283
setTimeout(() => $('#setup-site-name').focus(), 50);
22482284
}
22492285

0 commit comments

Comments
 (0)