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.9",
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",
@@ -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
@@ -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 = '';
37
37
  const DIM = '';
38
38
  const BOLD = '';
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
- const ORIGIN = (target.startsWith('http') ? target : `https://${target}`).replace(/\/$/, '');
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
- return await fetch(url, { redirect: 'follow', signal: controller.signal, ...options });
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 vendors = VENDORS.filter((v) => new RegExp(v.replace('.', '\\.'), 'i').test(corpus));
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\./, '')))