create-website-build-kit 0.1.9 → 0.1.11
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-website-build-kit",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.11",
|
|
4
4
|
"description": "Scaffold a production marketing site \u2014 Astro on Cloudflare Workers, with the gates, the migration playbook and the accessibility work already wired.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"astro",
|
package/template/CLAUDE.md
CHANGED
|
@@ -13,6 +13,7 @@ npm run a11y # accessibility check, one URL per
|
|
|
13
13
|
npm run tells # what is undecided, and the design tells
|
|
14
14
|
npm run check:copy # author notes that reached the rendered page
|
|
15
15
|
npm run recon -- https://old-site.com # inventory the old site BEFORE designing routes
|
|
16
|
+
# --allow-internal # ...if the old site is on a VPN or a private address
|
|
16
17
|
npm run dns -- old-site.com # capture the zone. MX loss kills client email
|
|
17
18
|
npm run seo -- https://old-site.com # optional: SEO baseline to diff after cutover
|
|
18
19
|
npm run verify -- https://new.example.com # the deployed site, not the build. exits non-zero
|
|
@@ -94,6 +95,12 @@ The rules whose failure looks like success — double-counted pageviews, a conve
|
|
|
94
95
|
two pipes, a trigger on `sent=1` that catches almost nothing — are in `docs/analytics.md`.
|
|
95
96
|
Read it before adding any tag.
|
|
96
97
|
|
|
98
|
+
⚠ **THE HONEYPOT IS CALLED `company`.** `api/contact.ts` discards any submission that fills it in,
|
|
99
|
+
silently and with a 200, so a bot learns nothing. Add a real "Company" field to that form — an
|
|
100
|
+
ordinary client request — and every enquiry from a company that types its name is thrown away, with
|
|
101
|
+
a thank-you page and nothing stored. **Name the real field `companyName`** and leave the trap alone;
|
|
102
|
+
`npm run check:form` fails the build if two controls share a name.
|
|
103
|
+
|
|
97
104
|
**Notes to yourself never ship.** `check:copy` reads the text a browser would show — not the
|
|
98
105
|
source, not comments, not `<script>` — and looks for the markers people actually leave: `TODO`,
|
|
99
106
|
`FIXME`, `⚠ CONFIRM:`, `Lorem ipsum`, an unrendered `{{ placeholder }}`. It warns on staging and
|
package/template/package.json
CHANGED
|
@@ -35,7 +35,8 @@
|
|
|
35
35
|
"deploy:staging": "npm run build:staging && wrangler deploy && node scripts/check-secrets.mjs",
|
|
36
36
|
"deploy:production": "npm run build:production && wrangler deploy && node scripts/check-secrets.mjs",
|
|
37
37
|
"check:secrets": "node scripts/check-secrets.mjs",
|
|
38
|
-
"check:copy": "node scripts/check-copy.mjs"
|
|
38
|
+
"check:copy": "node scripts/check-copy.mjs",
|
|
39
|
+
"check:form": "node scripts/check-form.mjs"
|
|
39
40
|
},
|
|
40
41
|
"dependencies": {
|
|
41
42
|
"@astrojs/cloudflare": "^14.1.7",
|
|
@@ -96,6 +96,11 @@ if (env === 'production') {
|
|
|
96
96
|
run(['check']);
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
+
/* Before the build, not after: a duplicate field name is a source bug, and
|
|
100
|
+
there is no reason to spend a build discovering it. Fails in BOTH
|
|
101
|
+
environments — nobody ever meant two controls to share a name. */
|
|
102
|
+
step(process.execPath, ['scripts/check-form.mjs']);
|
|
103
|
+
|
|
99
104
|
run(['build']);
|
|
100
105
|
|
|
101
106
|
if (env === 'staging') {
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Refuse a build where two form controls share a `name`.
|
|
3
|
+
*
|
|
4
|
+
* npm run check:form
|
|
5
|
+
*
|
|
6
|
+
* Runs inside `build:staging` and `build:production`. This is a correctness
|
|
7
|
+
* bug, not a note, so it fails in both — a duplicate `name` is never something
|
|
8
|
+
* anyone meant.
|
|
9
|
+
*
|
|
10
|
+
* ── THE FAILURE THIS EXISTS FOR ────────────────────────────────────────────
|
|
11
|
+
* ⚠ THE HONEYPOT IS CALLED `company`, AND THAT IS THE FIELD A B2B SITE ADDS.
|
|
12
|
+
*
|
|
13
|
+
* `ContactForm.astro` hides a trap field named `company`, and `api/contact.ts`
|
|
14
|
+
* discards any submission that fills it in — **silently and with a 200**, so a
|
|
15
|
+
* bot learns nothing:
|
|
16
|
+
*
|
|
17
|
+
* if (input.company) return seeOther(FORM_PAGE) // or { ok: true }
|
|
18
|
+
*
|
|
19
|
+
* Add a real "Company" field to that form, as any business site eventually
|
|
20
|
+
* does, and every enquiry from a company that types its name is thrown away.
|
|
21
|
+
* The form returns 200, the thank-you page renders, nothing is stored, nothing
|
|
22
|
+
* is logged. It is `check-secrets` all over again — leads vanishing while the
|
|
23
|
+
* site looks like it is working — except this one arrives as an ordinary client
|
|
24
|
+
* request rather than a mistake.
|
|
25
|
+
*
|
|
26
|
+
* It has already happened on a shipped build. The fix there was to name the
|
|
27
|
+
* real field `companyName`, which is right and is what this check tells you.
|
|
28
|
+
*
|
|
29
|
+
* ── WHY THE SOURCE AND NOT dist/ ───────────────────────────────────────────
|
|
30
|
+
* The contact route is `prerender = false`, so the form is not in the build
|
|
31
|
+
* output to inspect. Reading the component catches it before a deploy rather
|
|
32
|
+
* than after, which for a lead-loss bug is the difference that matters.
|
|
33
|
+
*
|
|
34
|
+
* ── WHY NOT JUST RENAME THE HONEYPOT ───────────────────────────────────────
|
|
35
|
+
* It has to look plausible to a bot, and every plausible name — `company`,
|
|
36
|
+
* `website`, `fax`, `url` — is a field some real form wants. Moving the trap
|
|
37
|
+
* moves the landmine. A check is the answer that does not depend on guessing
|
|
38
|
+
* which name nobody will need.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
|
|
42
|
+
import { join, relative, sep } from 'node:path';
|
|
43
|
+
|
|
44
|
+
const RESET = '\x1b[0m';
|
|
45
|
+
const RED = '\x1b[31m';
|
|
46
|
+
const GREEN = '\x1b[32m';
|
|
47
|
+
const DIM = '\x1b[2m';
|
|
48
|
+
|
|
49
|
+
const SRC = 'src';
|
|
50
|
+
if (!existsSync(SRC)) {
|
|
51
|
+
console.error('check-form: no src/ — run from the project root.');
|
|
52
|
+
process.exit(1);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const walk = (dir) =>
|
|
56
|
+
readdirSync(dir).flatMap((e) => {
|
|
57
|
+
const full = join(dir, e);
|
|
58
|
+
return statSync(full).isDirectory() ? walk(full) : [full];
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
/*
|
|
62
|
+
* A control is an input, select or textarea. `name` on anything else — a
|
|
63
|
+
* <meta>, an <a>, a slot — is not a form field and must not be counted.
|
|
64
|
+
*
|
|
65
|
+
* Only QUOTED values are captured, which is also how an expression-built name
|
|
66
|
+
* is skipped: Astro writes those unquoted — name={`f-${i}`} — so they never
|
|
67
|
+
* match, and there is nothing to compare anyway.
|
|
68
|
+
*
|
|
69
|
+
* ⚠ There was a guard here testing the captured name for `{` and `$`. It could
|
|
70
|
+
* never fire, because a captured name is quoted by definition — and it would
|
|
71
|
+
* have wrongly skipped a real literal like name="f-{i}". Found by mutation:
|
|
72
|
+
* deleting it changed no test, which is what an unreachable line looks like.
|
|
73
|
+
*/
|
|
74
|
+
const CONTROL = /<(input|select|textarea)\b[^>]*?\bname=["']([^"']+)["'][^>]*>/gis;
|
|
75
|
+
|
|
76
|
+
/** The trap is marked by its wrapper class, so it can be named in the message. */
|
|
77
|
+
const isHoneypot = (html, index) => html.lastIndexOf('form__trap', index) > html.lastIndexOf('</div>', index);
|
|
78
|
+
|
|
79
|
+
const problems = [];
|
|
80
|
+
|
|
81
|
+
for (const file of walk(SRC).filter((f) => f.endsWith('.astro'))) {
|
|
82
|
+
const html = readFileSync(file, 'utf8');
|
|
83
|
+
if (!/<form\b/i.test(html) && !/form__trap/.test(html)) continue;
|
|
84
|
+
|
|
85
|
+
const seen = new Map();
|
|
86
|
+
for (const m of html.matchAll(CONTROL)) {
|
|
87
|
+
const name = m[2];
|
|
88
|
+
const at = seen.get(name);
|
|
89
|
+
if (at === undefined) {
|
|
90
|
+
seen.set(name, m.index);
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
problems.push({
|
|
94
|
+
file: relative(process.cwd(), file).split(sep).join('/'),
|
|
95
|
+
name,
|
|
96
|
+
honeypot: isHoneypot(html, at) || isHoneypot(html, m.index),
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!problems.length) {
|
|
102
|
+
console.log(`${GREEN}✓${RESET} no duplicate form field names`);
|
|
103
|
+
process.exit(0);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
console.error(`\n${RED}✗ ${problems.length} duplicate form field name(s)${RESET}\n`);
|
|
107
|
+
for (const p of problems) {
|
|
108
|
+
console.error(` ${p.file} → name="${p.name}"`);
|
|
109
|
+
if (p.honeypot) {
|
|
110
|
+
console.error(
|
|
111
|
+
` ${DIM}This is the HONEYPOT name. api/contact.ts discards any submission\n` +
|
|
112
|
+
` that fills it in, silently and with a 200 — so every enquiry from a\n` +
|
|
113
|
+
` company that types its name would be thrown away, with a thank-you\n` +
|
|
114
|
+
` page and nothing stored.\n\n` +
|
|
115
|
+
` Rename the REAL field — companyName, organisation — and leave the\n` +
|
|
116
|
+
` trap alone.${RESET}`,
|
|
117
|
+
);
|
|
118
|
+
} else {
|
|
119
|
+
console.error(
|
|
120
|
+
` ${DIM}Two controls posting the same key: the second overwrites the first\n` +
|
|
121
|
+
` in formData, so one of them is silently discarded.${RESET}`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
console.error('');
|
|
126
|
+
process.exit(1);
|
|
@@ -37,27 +37,156 @@ const YELLOW = '[33m';
|
|
|
37
37
|
const DIM = '[2m';
|
|
38
38
|
const BOLD = '[1m';
|
|
39
39
|
|
|
40
|
+
/*
|
|
41
|
+
* ── WHAT THIS BLOCKS, AND WHAT IT DOES NOT ─────────────────────────────────
|
|
42
|
+
* Loopback, the RFC1918 private ranges, link-local (which is where the cloud
|
|
43
|
+
* metadata services live, at 169.254.169.254) and localhost. The threat is a
|
|
44
|
+
* redirect: the OLD site is not ours, and a 302 it issues must not be able to
|
|
45
|
+
* steer this crawler at infrastructure on the operator's network.
|
|
46
|
+
*
|
|
47
|
+
* ⚠ THIS IS A STRING BLOCKLIST AND IT ONLY SEES LITERAL ADDRESSES. A HOSTNAME
|
|
48
|
+
* THAT *RESOLVES* TO LOOPBACK WALKS STRAIGHT THROUGH — `localtest.me` is a
|
|
49
|
+
* public name that resolves to ::1 today, and any attacker can point their
|
|
50
|
+
* own name wherever they like. Closing that needs resolution before connect
|
|
51
|
+
* plus a pinned socket, which fetch does not expose.
|
|
52
|
+
*
|
|
53
|
+
* So treat this as defence in depth, not a barrier. It raises the cost of
|
|
54
|
+
* the obvious attack; it does not make the crawler safe to point at a host
|
|
55
|
+
* you do not trust.
|
|
56
|
+
*
|
|
57
|
+
* Node's URL parser canonicalises before we ever see the host, which is why
|
|
58
|
+
* the short forms need no special handling: 127.1, 2130706433 and 0177.0.0.1
|
|
59
|
+
* all arrive as 127.0.0.1, and [0:0:0:0:0:0:0:1] arrives as ::1.
|
|
60
|
+
*
|
|
61
|
+
* ⚠ IT CANONICALISES IPv4-MAPPED IPv6 THE WRONG WAY FOR US. `::ffff:127.0.0.1`
|
|
62
|
+
* comes back as `[::ffff:7f00:1]` — the same address in hex — so a blocklist
|
|
63
|
+
* written in dotted quad never matches it. It has to be folded back by hand,
|
|
64
|
+
* which is what unmapV4 does. Stripping the literal `::ffff:` prefix is NOT
|
|
65
|
+
* enough and looks like it works.
|
|
66
|
+
*/
|
|
67
|
+
const BLOCKED_HOST_RE =
|
|
68
|
+
/^(127(?:\.\d{1,3}){3}|10(?:\.\d{1,3}){3}|172\.(?:1[6-9]|2\d|3[01])(?:\.\d{1,3}){2}|192\.168(?:\.\d{1,3}){2}|169\.254(?:\.\d{1,3}){2}|0\.0\.0\.0|localhost|::1|metadata\.google\.internal)$/i;
|
|
69
|
+
|
|
70
|
+
/** `::ffff:7f00:1` → `127.0.0.1`. Returns the host unchanged if it is not mapped. */
|
|
71
|
+
function unmapV4(host) {
|
|
72
|
+
const hex = /^::ffff:([0-9a-f]{1,4}):([0-9a-f]{1,4})$/i.exec(host);
|
|
73
|
+
if (hex) {
|
|
74
|
+
const [hi, lo] = [parseInt(hex[1], 16), parseInt(hex[2], 16)];
|
|
75
|
+
return [hi >> 8, hi & 0xff, lo >> 8, lo & 0xff].join('.');
|
|
76
|
+
}
|
|
77
|
+
return host.replace(/^::ffff:/i, '');
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Why this URL is refused, or null if it is fine. Never throws.
|
|
82
|
+
*
|
|
83
|
+
* The `kind` matters: only a `host` refusal is something --allow-internal can
|
|
84
|
+
* excuse. A bad protocol is a typo, and telling someone to pass a flag that
|
|
85
|
+
* cannot help them is worse than saying nothing.
|
|
86
|
+
*/
|
|
87
|
+
function blockedReason(url, { allowInternal = false } = {}) {
|
|
88
|
+
let parsed;
|
|
89
|
+
try {
|
|
90
|
+
parsed = new URL(url);
|
|
91
|
+
} catch {
|
|
92
|
+
return { kind: 'url', reason: `unparseable URL: ${url}` };
|
|
93
|
+
}
|
|
94
|
+
const { protocol, hostname } = parsed;
|
|
95
|
+
if (protocol !== 'http:' && protocol !== 'https:') {
|
|
96
|
+
return { kind: 'protocol', reason: `blocked protocol: ${protocol}` };
|
|
97
|
+
}
|
|
98
|
+
if (allowInternal) return null;
|
|
99
|
+
const host = unmapV4(hostname.replace(/^\[|\]$/g, ''));
|
|
100
|
+
if (BLOCKED_HOST_RE.test(host)) return { kind: 'host', reason: `blocked internal host: ${hostname}` };
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
|
|
40
104
|
const argv = process.argv.slice(2);
|
|
41
105
|
const target = argv.find((a) => !a.startsWith('--'));
|
|
42
106
|
const useWayback = !argv.includes('--no-wayback');
|
|
107
|
+
const allowInternal = argv.includes('--allow-internal');
|
|
43
108
|
|
|
44
109
|
if (!target) {
|
|
45
|
-
console.error('usage: npm run recon -- https://old-site.com [--no-wayback]');
|
|
110
|
+
console.error('usage: npm run recon -- https://old-site.com [--no-wayback] [--allow-internal]');
|
|
46
111
|
process.exit(1);
|
|
47
112
|
}
|
|
48
113
|
|
|
49
|
-
|
|
114
|
+
/*
|
|
115
|
+
* ⚠ ONLY PREPEND A SCHEME WHEN THERE IS NONE. `target.startsWith('http')` was
|
|
116
|
+
* the old test, and it turned `file:///etc/passwd` into
|
|
117
|
+
* `https://file:///etc/passwd` — which parses, with hostname `file`, so the
|
|
118
|
+
* protocol check below could never fire on a target the user typed.
|
|
119
|
+
*/
|
|
120
|
+
const hasScheme = /^[a-z][a-z0-9+.-]*:/i.test(target);
|
|
121
|
+
const ORIGIN = (hasScheme ? target : `https://${target}`).replace(/\/$/, '');
|
|
50
122
|
const HOST = new URL(ORIGIN).hostname;
|
|
51
123
|
const OUT = 'recon';
|
|
52
124
|
|
|
125
|
+
/*
|
|
126
|
+
* The target is checked too, not just the redirects. An old site behind a VPN
|
|
127
|
+
* on a private address is a real thing to recon, so this is a flag rather than
|
|
128
|
+
* a refusal — but it has to be asked for, because the default has to be the
|
|
129
|
+
* safe one and `--allow-internal` also relaxes the redirect check below.
|
|
130
|
+
*/
|
|
131
|
+
const originRefusal = blockedReason(ORIGIN, { allowInternal });
|
|
132
|
+
if (originRefusal) {
|
|
133
|
+
const hint =
|
|
134
|
+
originRefusal.kind === 'host'
|
|
135
|
+
? ` recon crawls the old LIVE site, so an internal address is usually a typo.\n` +
|
|
136
|
+
` If it is not — the old site is on a VPN, or behind a private address —\n` +
|
|
137
|
+
` pass ${BOLD}--allow-internal${RESET} and it will crawl it.\n`
|
|
138
|
+
: ` recon speaks http and https. Give it the URL you would type into a browser.\n`;
|
|
139
|
+
console.error(`\n${RED}✗ ${originRefusal.reason}${RESET}\n\n${hint}`);
|
|
140
|
+
process.exit(1);
|
|
141
|
+
}
|
|
142
|
+
|
|
53
143
|
const section = (t) => console.log(`\n${BOLD}── ${t} ${'─'.repeat(Math.max(0, 56 - t.length))}${RESET}`);
|
|
54
144
|
const notes = [];
|
|
55
145
|
|
|
146
|
+
/*
|
|
147
|
+
* ⚠ A REFUSAL IS NOT A NETWORK ERROR, AND MUST NOT LOOK LIKE ONE.
|
|
148
|
+
*
|
|
149
|
+
* req() returns null when a fetch fails, and every caller reads that as "the
|
|
150
|
+
* old site did not answer". If a blocked host returned null the same way, a
|
|
151
|
+
* refused crawl would be reported as an unreachable site: recon would print a
|
|
152
|
+
* thin inventory, exit 0, and nobody would learn that pages were skipped on
|
|
153
|
+
* purpose. That is the exact shape of failure this kit exists to prevent, so
|
|
154
|
+
* a refusal says so on stdout AND lands in the notes at the end of the run.
|
|
155
|
+
*
|
|
156
|
+
* Deduplicated by reason: a site that redirects every path to the same
|
|
157
|
+
* internal host would otherwise print one line per URL.
|
|
158
|
+
*/
|
|
159
|
+
const refusals = new Set();
|
|
160
|
+
|
|
161
|
+
function refuse(reason, url) {
|
|
162
|
+
if (!refusals.has(reason)) {
|
|
163
|
+
refusals.add(reason);
|
|
164
|
+
console.log(` ${YELLOW}refused${RESET} ${reason}`);
|
|
165
|
+
notes.push(
|
|
166
|
+
`Refused to fetch ${url} — ${reason}. This was NOT a network error: the crawl skipped it ` +
|
|
167
|
+
`deliberately, so the inventory is incomplete. Re-run with --allow-internal if that host is yours.`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
56
173
|
async function req(url, options = {}) {
|
|
174
|
+
const refusal = blockedReason(url, { allowInternal });
|
|
175
|
+
if (refusal) return refuse(refusal.reason, url);
|
|
176
|
+
|
|
57
177
|
const controller = new AbortController();
|
|
58
178
|
const timer = setTimeout(() => controller.abort(), 20000);
|
|
179
|
+
const follow = options.redirect !== 'manual';
|
|
59
180
|
try {
|
|
60
|
-
|
|
181
|
+
let res = await fetch(url, { ...options, redirect: 'manual', signal: controller.signal });
|
|
182
|
+
for (let hops = 0; follow && res.status >= 300 && res.status < 400 && res.headers.get('location') && hops < 5; hops++) {
|
|
183
|
+
const next = new URL(res.headers.get('location'), url).toString();
|
|
184
|
+
const hopRefusal = blockedReason(next, { allowInternal });
|
|
185
|
+
if (hopRefusal) return refuse(`${hopRefusal.reason} — reached by a redirect from ${url}`, next);
|
|
186
|
+
url = next;
|
|
187
|
+
res = await fetch(url, { ...options, redirect: 'manual', signal: controller.signal });
|
|
188
|
+
}
|
|
189
|
+
return res;
|
|
61
190
|
} catch {
|
|
62
191
|
return null;
|
|
63
192
|
} finally {
|
|
@@ -407,7 +536,8 @@ const VENDORS = [
|
|
|
407
536
|
'jobber', 'servicetitan', 'momence', 'wellnessliving', 'glofox', 'pike13', 'cookieyes', 'cookiebot', 'complianz', 'algolia', 'mindbody',
|
|
408
537
|
'squarespace', 'wix', 'shopify', 'woocommerce', 'memberpress',
|
|
409
538
|
];
|
|
410
|
-
const
|
|
539
|
+
const corpusLower = corpus.toLowerCase();
|
|
540
|
+
const vendors = VENDORS.filter((v) => corpusLower.includes(v.toLowerCase()));
|
|
411
541
|
|
|
412
542
|
const origins = [...new Set([...corpus.matchAll(/(?:src|href)=["']https?:\/\/([^"'/]+)/g)].map((m) => m[1]))]
|
|
413
543
|
.filter((h) => !h.endsWith(HOST.replace(/^www\./, '')))
|