@cparkerwebm/webmonterey 1.0.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.
Files changed (133) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/LICENSE +21 -0
  3. package/README.md +104 -0
  4. package/agents/.gitkeep +0 -0
  5. package/dist/webm.mjs +2381 -0
  6. package/hooks/.gitkeep +0 -0
  7. package/package.json +101 -0
  8. package/schema/design.json +118 -0
  9. package/skills/launch/SKILL.md +183 -0
  10. package/skills/new-component/SKILL.md +85 -0
  11. package/skills/start/SKILL.md +117 -0
  12. package/skills/traps/SKILL.md +333 -0
  13. package/skills/upgrade/SKILL.md +42 -0
  14. package/src/actions/index.ts +348 -0
  15. package/src/cli/checks.test.ts +711 -0
  16. package/src/cli/checks.ts +822 -0
  17. package/src/cli/codemods.test.ts +42 -0
  18. package/src/cli/codemods.ts +51 -0
  19. package/src/cli/compare.test.ts +144 -0
  20. package/src/cli/compare.ts +222 -0
  21. package/src/cli/design-extract.test.ts +96 -0
  22. package/src/cli/design-extract.ts +229 -0
  23. package/src/cli/doctor.ts +187 -0
  24. package/src/cli/mcp.test.ts +57 -0
  25. package/src/cli/mcp.ts +110 -0
  26. package/src/cli/new.ts +148 -0
  27. package/src/cli/package-root.ts +58 -0
  28. package/src/cli/scaffold.test.ts +230 -0
  29. package/src/cli/scaffold.ts +424 -0
  30. package/src/cli/seed.ts +133 -0
  31. package/src/cli/slug.test.ts +70 -0
  32. package/src/cli/slug.ts +109 -0
  33. package/src/cli/sync.test.ts +137 -0
  34. package/src/cli/sync.ts +266 -0
  35. package/src/cli/upgrade.ts +93 -0
  36. package/src/design/__fixtures__/tokens-v1.4.1.css +227 -0
  37. package/src/design/brand.ts +49 -0
  38. package/src/design/compile.test.ts +98 -0
  39. package/src/design/compile.ts +155 -0
  40. package/src/design/defaults.ts +315 -0
  41. package/src/design/index.ts +18 -0
  42. package/src/design/resolve.test.ts +80 -0
  43. package/src/design/resolve.ts +108 -0
  44. package/src/design/types.ts +96 -0
  45. package/src/emails/autoresponse.test.ts +82 -0
  46. package/src/emails/autoresponse.ts +83 -0
  47. package/src/emails/footer.test.ts +102 -0
  48. package/src/emails/footer.ts +91 -0
  49. package/src/emails/index.ts +24 -0
  50. package/src/emails/subject.test.ts +66 -0
  51. package/src/emails/subject.ts +80 -0
  52. package/src/emails/submission-notification.ts +76 -0
  53. package/src/env.d.ts +12 -0
  54. package/src/includes/cloudflare/d1/client.ts +86 -0
  55. package/src/includes/cloudflare/r2/README.md +68 -0
  56. package/src/includes/cloudflare/r2/media.ts +41 -0
  57. package/src/includes/cloudflare/r2/url.test.ts +44 -0
  58. package/src/includes/cloudflare/r2/url.ts +34 -0
  59. package/src/includes/cloudflare/turnstile/Turnstile.astro +161 -0
  60. package/src/includes/cloudflare/turnstile/verify.ts +157 -0
  61. package/src/includes/cloudflare/workers/env.ts +56 -0
  62. package/src/includes/google/tag-manager/TagManager.astro +73 -0
  63. package/src/includes/sinch/mailgun/redirect.test.ts +96 -0
  64. package/src/includes/sinch/mailgun/redirect.ts +96 -0
  65. package/src/includes/sinch/mailgun/send.ts +186 -0
  66. package/src/includes/webmonterey/animations/animations.css +347 -0
  67. package/src/includes/webmonterey/animations/observe.test.ts +88 -0
  68. package/src/includes/webmonterey/animations/observe.ts +209 -0
  69. package/src/includes/webmonterey/compliance/ConsentInit.astro +74 -0
  70. package/src/includes/webmonterey/compliance/CookieConsent.astro +558 -0
  71. package/src/includes/webmonterey/compliance/consent-styles.test.ts +83 -0
  72. package/src/includes/webmonterey/compliance/consent.ts +142 -0
  73. package/src/includes/webmonterey/config.test.ts +94 -0
  74. package/src/includes/webmonterey/config.ts +346 -0
  75. package/src/includes/webmonterey/copy-defaults.ts +148 -0
  76. package/src/includes/webmonterey/copy.ts +13 -0
  77. package/src/includes/webmonterey/credits/Credit.astro +80 -0
  78. package/src/includes/webmonterey/credits/credit.test.ts +111 -0
  79. package/src/includes/webmonterey/credits/credit.ts +59 -0
  80. package/src/includes/webmonterey/forms/honeypot.test.ts +40 -0
  81. package/src/includes/webmonterey/forms/honeypot.ts +66 -0
  82. package/src/includes/webmonterey/prose/inline.test.ts +94 -0
  83. package/src/includes/webmonterey/prose/inline.ts +71 -0
  84. package/src/includes/webmonterey/scroll-top/ScrollTop.astro +209 -0
  85. package/src/includes/webmonterey/site.ts +136 -0
  86. package/src/includes/webmonterey/structured-data/nodes.ts +315 -0
  87. package/src/includes/webmonterey/zoned-hour.test.ts +49 -0
  88. package/src/integration/adapter.ts +53 -0
  89. package/src/integration/app-middleware.ts +43 -0
  90. package/src/integration/config.ts +96 -0
  91. package/src/integration/content.ts +66 -0
  92. package/src/integration/image-size.test.ts +83 -0
  93. package/src/integration/image-size.ts +100 -0
  94. package/src/integration/index.ts +386 -0
  95. package/src/integration/virtual.d.ts +101 -0
  96. package/src/layouts/base.astro +402 -0
  97. package/src/package.test.ts +121 -0
  98. package/src/pages/404.astro +33 -0
  99. package/src/pages/[...slug].astro +126 -0
  100. package/src/pages/robots.txt.ts +41 -0
  101. package/src/pages/webm.astro +23 -0
  102. package/src/scripts/.gitkeep +0 -0
  103. package/src/styles/base.css +185 -0
  104. package/src/styles/global.css +47 -0
  105. package/src/styles/layers.test.ts +32 -0
  106. package/src/styles/layers.ts +21 -0
  107. package/src/styles/layout.css +235 -0
  108. package/src/styles/reset.css +135 -0
  109. package/src/styles/utilities.css +90 -0
  110. package/src/worker.ts +76 -0
  111. package/template/assets/logo.svg +14 -0
  112. package/template/assets/open-graph.png +0 -0
  113. package/template/migrations/0001_create_submissions.sql +40 -0
  114. package/template/migrations/README.md +41 -0
  115. package/template/public/android-chrome-192x192.png +0 -0
  116. package/template/public/android-chrome-512x512.png +0 -0
  117. package/template/public/apple-touch-icon.png +0 -0
  118. package/template/public/favicon-16x16.png +0 -0
  119. package/template/public/favicon-32x32.png +0 -0
  120. package/template/public/favicon.ico +0 -0
  121. package/template/public/favicon.svg +4 -0
  122. package/template/public/open-graph.png +0 -0
  123. package/template/scripts/check-node.mjs +72 -0
  124. package/template/scripts/test-hooks.mjs +96 -0
  125. package/template/site/.editorconfig +22 -0
  126. package/template/site/.prettierignore +15 -0
  127. package/template/site/.prettierrc.json +17 -0
  128. package/template/site/CLAUDE.md +245 -0
  129. package/template/site/CONTENT.md +135 -0
  130. package/template/site/PRIVACY-POLICY-DRAFT.md +81 -0
  131. package/template/site/public/_headers +70 -0
  132. package/template/site/src/forms/contact.json +45 -0
  133. package/template/workflows/ci.yml +52 -0
@@ -0,0 +1,86 @@
1
+ /*
2
+ * Thin helpers over Cloudflare D1.
3
+ *
4
+ * D1 holds PRIVATE data — form submissions, user accounts, anything that is not public page
5
+ * content. Public marketing copy belongs in src/content/pages/*.json, never here.
6
+ *
7
+ * Every helper takes the database as its first argument rather than reaching for the binding
8
+ * itself, so this module stays honest about its dependency and typechecks before any binding
9
+ * exists. Callers do:
10
+ *
11
+ * export const prerender = false; // REQUIRED — bindings are unavailable when prerendering
12
+ *
13
+ * import { env } from 'cloudflare:workers';
14
+ * import { all } from '../includes/cloudflare/d1/client.ts';
15
+ *
16
+ * const rows = await all(env.DB, 'SELECT * FROM submissions WHERE form = ?', 'contact');
17
+ *
18
+ * Before `env.DB` typechecks, the binding must be in wrangler.jsonc and `wrangler types` must
19
+ * have re-run. Until then use `getBinding<D1Database>('DB')` from ../workers/env.
20
+ *
21
+ * ALWAYS pass values as bound parameters. Never build SQL by string concatenation.
22
+ *
23
+ * The one thing you CANNOT bind is an identifier — a table or column name. `ORDER BY ?` does
24
+ * not work, so a sortable table tempts you into `ORDER BY ${column}`, which is an injection.
25
+ * Map untrusted input through an allowlist instead, so only names you wrote can ever appear:
26
+ *
27
+ * const SORTABLE = { name: 'name', date: 'created_at' } as const;
28
+ * const column = SORTABLE[input as keyof typeof SORTABLE] ?? 'created_at';
29
+ * const rows = await all(env.DB, `SELECT * FROM submissions ORDER BY ${column} DESC`);
30
+ */
31
+
32
+ /** Bind params only when there are some — `.bind()` with no arguments is an error in D1. */
33
+ function prepare(db: D1Database, sql: string, params: unknown[]) {
34
+ const statement = db.prepare(sql);
35
+ return params.length ? statement.bind(...params) : statement;
36
+ }
37
+
38
+ /** Every matching row. Returns an empty array when there are none. */
39
+ export async function all<T = Record<string, unknown>>(
40
+ db: D1Database,
41
+ sql: string,
42
+ ...params: unknown[]
43
+ ): Promise<T[]> {
44
+ const { results } = await prepare(db, sql, params).all<T>();
45
+ return results ?? [];
46
+ }
47
+
48
+ /** The first matching row, or null. */
49
+ export async function first<T = Record<string, unknown>>(
50
+ db: D1Database,
51
+ sql: string,
52
+ ...params: unknown[]
53
+ ): Promise<T | null> {
54
+ return await prepare(db, sql, params).first<T>();
55
+ }
56
+
57
+ /**
58
+ * For INSERT / UPDATE / DELETE. Read `meta.last_row_id` and `meta.changes` off the result.
59
+ *
60
+ * Returns `D1Result<T>` (not just `D1Response`) so `INSERT … RETURNING id` can read
61
+ * `.results` without a type error.
62
+ */
63
+ export async function run<T = Record<string, unknown>>(
64
+ db: D1Database,
65
+ sql: string,
66
+ ...params: unknown[]
67
+ ): Promise<D1Result<T>> {
68
+ return await prepare(db, sql, params).run<T>();
69
+ }
70
+
71
+ /**
72
+ * Run several statements as one atomic batch.
73
+ *
74
+ * D1 has no interactive transactions — this is the only way to get all-or-nothing behavior.
75
+ *
76
+ * await batch(env.DB, [
77
+ * env.DB.prepare('INSERT INTO submissions (form, body) VALUES (?, ?)').bind(form, body),
78
+ * env.DB.prepare('UPDATE counters SET n = n + 1 WHERE name = ?').bind(form),
79
+ * ]);
80
+ */
81
+ export async function batch<T = Record<string, unknown>>(
82
+ db: D1Database,
83
+ statements: D1PreparedStatement[],
84
+ ): Promise<D1Result<T>[]> {
85
+ return await db.batch<T>(statements);
86
+ }
@@ -0,0 +1,68 @@
1
+ # R2 media — `media.<client-domain>`
2
+
3
+ Large or numerous files that should not live in git: video, audio, PDFs, galleries, downloads,
4
+ and anything migrated wholesale out of a WordPress uploads folder.
5
+
6
+ `media.ts` builds URLs. A binding is only needed if the Worker itself reads or writes objects —
7
+ serving them to visitors goes through the custom domain and never touches the Worker.
8
+
9
+ ## Setup
10
+
11
+ Do this **after** `go-live` has moved the zone onto the agency Cloudflare account. A custom
12
+ domain on a bucket requires the zone to be on the same account as the bucket, so doing it
13
+ earlier fails for the same reason `preview.<client-domain>` does.
14
+
15
+ ```sh
16
+ npx wrangler r2 bucket create webm-<domain-dashed>-media
17
+ ```
18
+
19
+ Then in the dashboard: **R2 → the bucket → Settings → Custom Domains → Connect Domain**, and
20
+ enter `media.<client-domain>`. Cloudflare creates the DNS record itself.
21
+
22
+ **Do not enable the `r2.dev` subdomain.** It is rate-limited, Cloudflare documents it as
23
+ unsuitable for production, and it puts client media on a hostname the client does not own.
24
+
25
+ Only add a binding to `wrangler.jsonc` if the Worker reads or writes objects — an upload
26
+ endpoint, a signed download. Serving public media does not need one.
27
+
28
+ ## Bulk uploads: use rclone, not wrangler
29
+
30
+ `wrangler r2 object put` **caps at 300 MiB per object**, and there is no object-listing or
31
+ recursive-download command at all. It cannot perform a migration; do not spend an afternoon
32
+ scripting around it.
33
+
34
+ Create a bucket-scoped R2 API token (Object Read & Write), then:
35
+
36
+ ```
37
+ [webm-media]
38
+ type = s3
39
+ provider = Cloudflare
40
+ access_key_id = <token id>
41
+ secret_access_key = <token secret>
42
+ endpoint = https://<account-id>.r2.cloudflarestorage.com
43
+ region = auto
44
+ no_check_bucket = true
45
+ ```
46
+
47
+ `no_check_bucket = true` is **required**, not tuning. A bucket-scoped token cannot
48
+ `CreateBucket`, and rclone probes for exactly that before its first upload — so without it
49
+ every transfer fails with an error naming an operation you never asked for.
50
+
51
+ ```sh
52
+ rclone copy ./uploads webm-media:webm-<domain-dashed>-media --transfers 2 --progress
53
+ rclone check ./uploads webm-media:webm-<domain-dashed>-media
54
+ ```
55
+
56
+ Two things learned the hard way:
57
+
58
+ - **`rclone check` is what proves a transfer**, not `rclone copy`'s exit code.
59
+ - **A slow uplink wants FEWER parallel transfers.** 8 streams on a ~17 Mbps uplink produced
60
+ constant connection timeouts; 2 streams did not. Raising `--transfers` to fix slowness makes
61
+ it worse.
62
+
63
+ ## Local and remote are separate stores
64
+
65
+ `wrangler dev` writes to a local simulated bucket under `.wrangler/`. Objects **never** move
66
+ between it and production, in either direction, and nothing warns you. Code moves on push;
67
+ objects do not move at all. A deployed site starts with an empty bucket and has to be seeded
68
+ there. Same rule as D1 — see CLAUDE.md.
@@ -0,0 +1,41 @@
1
+ /*
2
+ * R2 media, served from `media.<client-domain>`.
3
+ *
4
+ * WHAT BELONGS HERE, and what does not:
5
+ *
6
+ * src/assets/ images the DESIGN uses — imported, hashed, optimised at build by sharp.
7
+ * A logo, an icon, a hero shot. These belong in the repo.
8
+ * public/ small fixed files that need a stable URL (favicons, open-graph.png).
9
+ * R2 (here) everything too large or too numerous to sit in git: video, audio, PDFs,
10
+ * photo galleries, downloads, anything migrated wholesale off a WordPress
11
+ * uploads folder.
12
+ *
13
+ * The deciding question is not file size, it is "should `git clone` carry this?" A 40MB
14
+ * showreel makes every clone slower forever and can never be optimised by the build.
15
+ *
16
+ * NAMING (rule 5):
17
+ * bucket webm-<domain-dashed>-media e.g. webm-example-com-media
18
+ * hostname media.<client-domain> e.g. media.example.com
19
+ *
20
+ * WHY A CUSTOM DOMAIN AND NOT r2.dev: Cloudflare's r2.dev subdomain is rate-limited and
21
+ * documented as unsuitable for production. It is also a hostname the client does not own,
22
+ * which puts their media on someone else's brand. A custom domain is also the only way the
23
+ * objects get Cache-Reserve and normal zone caching.
24
+ *
25
+ * THE ZONE MUST BE ON THE SAME CLOUDFLARE ACCOUNT AS THE BUCKET. This is the same constraint
26
+ * that makes `preview.<client-domain>` unusable before launch (see CLAUDE.md Traps) — but
27
+ * unlike previews it is not a blocker here, because `go-live` moves the zone onto the account
28
+ * anyway. Set up media AFTER the zone move, not before.
29
+ */
30
+ import { domain, hasDomain } from '../../webmonterey/site.ts';
31
+ import { buildMediaUrl, mediaHostFor } from './url.ts';
32
+
33
+ export { buildMediaUrl, mediaHostFor };
34
+
35
+ /** The media hostname for this site, or null while `domain` is still CHANGEME. */
36
+ export const mediaHost = mediaHostFor(domain, hasDomain);
37
+
38
+ /** Absolute URL for an object in this site's media bucket. See buildMediaUrl. */
39
+ export function mediaUrl(key: string): string {
40
+ return buildMediaUrl(mediaHost, key);
41
+ }
@@ -0,0 +1,44 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { buildMediaUrl, mediaHostFor } from './url.ts';
4
+
5
+ /*
6
+ * Tests the PURE builder, not the config-bound wrapper.
7
+ *
8
+ * In generation 2 this file imported the bound module and had to branch on whether the site it
9
+ * happened to be running in was configured yet - so the assertions were about properties that
10
+ * hold in every configuration rather than about behavior. Separating buildMediaUrl from
11
+ * mediaUrl means the interesting case (an unconfigured domain) is testable directly, on any
12
+ * machine, with no build.
13
+ */
14
+
15
+ test('an absolute https URL is built from the host and key', () => {
16
+ assert.equal(
17
+ buildMediaUrl('media.example.com', 'video/reel.mp4'),
18
+ 'https://media.example.com/video/reel.mp4',
19
+ );
20
+ });
21
+
22
+ test('a leading slash on the key does not produce a double slash', () => {
23
+ assert.equal(
24
+ buildMediaUrl('media.example.com', '/video/reel.mp4'),
25
+ 'https://media.example.com/video/reel.mp4',
26
+ );
27
+ assert.equal(buildMediaUrl('media.example.com', '///a.pdf'), 'https://media.example.com/a.pdf');
28
+ });
29
+
30
+ test('a null host throws rather than returning a URL pointing at CHANGEME', () => {
31
+ // A dead image is only ever noticed by a visitor. Fail the build instead.
32
+ assert.throws(() => buildMediaUrl(null, 'a.pdf'), /CHANGEME/);
33
+ });
34
+
35
+ test('the error names the file and the field, so the fix is obvious from the message', () => {
36
+ assert.throws(() => buildMediaUrl(null, 'a.pdf'), /webmonterey\.json/);
37
+ assert.throws(() => buildMediaUrl(null, 'a.pdf'), /domain/);
38
+ });
39
+
40
+ test('mediaHostFor is null until the domain is configured', () => {
41
+ assert.equal(mediaHostFor('CHANGEME', false), null);
42
+ assert.equal(mediaHostFor(null, false), null);
43
+ assert.equal(mediaHostFor('example.com', true), 'media.example.com');
44
+ });
@@ -0,0 +1,34 @@
1
+ /*
2
+ * Pure media-URL construction. No config, no virtual module, no imports.
3
+ *
4
+ * Separated from media.ts so it is testable with `node --test` and no build. media.ts binds
5
+ * this to the site's own host; anything importing media.ts transitively pulls in
6
+ * virtual:webm/site, which only exists inside an Astro build.
7
+ *
8
+ * THE PATTERN, applied throughout the package: pure logic lives in a file with no virtual-module
9
+ * imports; the binding lives beside it. Generation 2 could not do this because everything shared
10
+ * one repo, so its tests had to branch on whether the site they happened to run in was configured
11
+ * yet - asserting properties that hold in every configuration rather than behavior.
12
+ */
13
+
14
+ /**
15
+ * Absolute URL for an object in the media bucket.
16
+ *
17
+ * Throws on a null host rather than returning a broken URL: a link silently pointing at
18
+ * `https://media.CHANGEME/…` renders as a dead image or a dead download, and the failure would
19
+ * only ever be noticed by a visitor.
20
+ */
21
+ export function buildMediaUrl(host: string | null, key: string): string {
22
+ if (!host) {
23
+ throw new Error(
24
+ '[webm] Cannot build a media URL: webmonterey.json "domain" is still CHANGEME. ' +
25
+ 'Set the production domain, or do not reference R2 media yet.',
26
+ );
27
+ }
28
+ return `https://${host}/${key.replace(/^\/+/, '')}`;
29
+ }
30
+
31
+ /** The media hostname for a domain, or null while it is still the placeholder. */
32
+ export function mediaHostFor(domain: string | null, configured: boolean): string | null {
33
+ return configured && domain ? `media.${domain}` : null;
34
+ }
@@ -0,0 +1,161 @@
1
+ ---
2
+ /*
3
+ * Cloudflare Turnstile widget. The client half; verify.ts is the server half.
4
+ *
5
+ * <form method="POST" action={actions.submitForm}>
6
+ * …fields…
7
+ * <Turnstile />
8
+ * <button type="submit">Send</button>
9
+ * </form>
10
+ *
11
+ * Injects a hidden `cf-turnstile-response` input that submits with the form. The server MUST
12
+ * verify that token — rendering this widget alone protects nothing.
13
+ *
14
+ * The SITE key is public and comes from `vars` in wrangler.jsonc as PUBLIC_TURNSTILE_SITE_KEY.
15
+ * The SECRET key never touches the client.
16
+ *
17
+ * NOT consent-gated. Turnstile is strictly necessary for a form to resist abuse, which makes
18
+ * it an essential cookie under CCPA/GDPR — it runs regardless of the visitor's choices. See
19
+ * the consent rules in CLAUDE.md.
20
+ */
21
+ interface Props {
22
+ /**
23
+ * Site key. Defaults to PUBLIC_TURNSTILE_SITE_KEY from the environment.
24
+ * Public by design — safe in markup.
25
+ */
26
+ siteKey?: string;
27
+ /**
28
+ * Distinguishes this form from others on the site, so a token minted by one cannot be
29
+ * replayed against another. Pass the SAME value to verifyTurnstile({ expectedAction }).
30
+ */
31
+ action?: string;
32
+ theme?: 'auto' | 'light' | 'dark';
33
+ size?: 'normal' | 'flexible' | 'compact';
34
+ }
35
+
36
+ const {
37
+ siteKey = import.meta.env.PUBLIC_TURNSTILE_SITE_KEY,
38
+ action,
39
+ theme = 'auto',
40
+ size = 'flexible',
41
+ } = Astro.props;
42
+
43
+ if (!siteKey) {
44
+ throw new Error(
45
+ '[webm] Turnstile has no site key. Set PUBLIC_TURNSTILE_SITE_KEY in `vars` in ' +
46
+ 'wrangler.jsonc (and in .dev.vars for local dev), or pass siteKey directly. ' +
47
+ 'This is the PUBLIC site key - the secret key belongs in `wrangler secret put`.',
48
+ );
49
+ }
50
+
51
+ // Test keys always pass and must never reach production. `go-live` checks for this too.
52
+ const isTestKey = /^[12]x/.test(siteKey);
53
+ ---
54
+
55
+ <div
56
+ class="cf-turnstile webm-turnstile"
57
+ data-sitekey={siteKey}
58
+ data-action={action}
59
+ data-theme={theme}
60
+ data-size={size}
61
+ data-error-callback="webmTurnstileError"
62
+ >
63
+ </div>
64
+
65
+ <!--
66
+ Surfaced failure. Without this the widget fails COMPLETELY silently: if it cannot render —
67
+ most commonly `110200`, meaning this hostname is not in the widget's Hostname Management —
68
+ no token is ever minted, the server rejects every submission 403, and the form looks
69
+ entirely normal. The only symptom in the DOM is a missing iframe, and the only symptom
70
+ anywhere is a console exception nobody has open.
71
+
72
+ A visitor filling this in deserves to be told before they type a message they are about to
73
+ lose. `hidden` works here because reset.css forces it — see CLAUDE.md Traps.
74
+ -->
75
+ <p class="webm-turnstile__error" role="alert" hidden data-webm-turnstile-error>
76
+ This form could not load its spam protection, so it cannot be submitted right now. Please reload
77
+ the page, or contact us another way if the problem continues.
78
+ </p>
79
+
80
+ {
81
+ isTestKey && (
82
+ <p class="webm-turnstile__warning" role="status">
83
+ Turnstile is using a <strong>test key</strong> — every submission passes. Replace it before
84
+ launch.
85
+ </p>
86
+ )
87
+ }
88
+
89
+ {
90
+ /*
91
+ Defined BEFORE the api.js tag below, and `is:inline` so Astro neither bundles nor defers it.
92
+ Turnstile resolves `data-error-callback` by name off `window` at the moment it fails, which
93
+ can be immediately on script load — a module-scoped or deferred function would not exist yet
94
+ and the failure would go back to being silent.
95
+ */
96
+ }
97
+ <script is:inline>
98
+ window.webmTurnstileError = function (code) {
99
+ /*
100
+ * The callback receives an error code, not the element that failed. A widget that failed
101
+ * to render has no iframe, which is what identifies it — and on the overwhelmingly common
102
+ * single-form page it is simply the only one.
103
+ */
104
+ document.querySelectorAll('.webm-turnstile').forEach(function (widget) {
105
+ if (widget.querySelector('iframe')) return;
106
+
107
+ var message =
108
+ widget.parentNode && widget.parentNode.querySelector('[data-webm-turnstile-error]');
109
+ if (message) message.hidden = false;
110
+
111
+ /*
112
+ * Disable submit. The server rejects these anyway — this replaces "the button does
113
+ * nothing" with an explanation, rather than changing whether the form can be sent.
114
+ */
115
+ var form = widget.closest('form');
116
+ if (form) {
117
+ form
118
+ .querySelectorAll('button[type="submit"], input[type="submit"]')
119
+ .forEach(function (button) {
120
+ button.disabled = true;
121
+ });
122
+ }
123
+ });
124
+
125
+ console.error(
126
+ '[webm] Turnstile failed to render (code ' +
127
+ code +
128
+ '). ' +
129
+ "A 110200 means this hostname is not listed in the widget's Hostname Management.",
130
+ );
131
+ };
132
+ </script>
133
+
134
+ <script is:inline async defer src="https://challenges.cloudflare.com/turnstile/v0/api.js"></script>
135
+
136
+ <style>
137
+ @layer webm.components.core {
138
+ .webm-turnstile {
139
+ /* Reserve the widget's height so the form does not jump when it loads. */
140
+ min-block-size: 4.0625rem;
141
+ }
142
+
143
+ .webm-turnstile__warning {
144
+ margin-block-start: var(--webm-space-2xs);
145
+ padding: var(--webm-space-2xs) var(--webm-space-xs);
146
+ border-radius: var(--webm-radius-sm);
147
+ background-color: var(--webm-surface-alt);
148
+ color: var(--webm-state-warning);
149
+ font-size: var(--webm-font-size-xs);
150
+ }
151
+
152
+ .webm-turnstile__error {
153
+ margin-block-start: var(--webm-space-2xs);
154
+ padding: var(--webm-space-2xs) var(--webm-space-xs);
155
+ border-radius: var(--webm-radius-sm);
156
+ background-color: var(--webm-surface-alt);
157
+ color: var(--webm-state-danger);
158
+ font-size: var(--webm-font-size-xs);
159
+ }
160
+ }
161
+ </style>
@@ -0,0 +1,157 @@
1
+ /*
2
+ * Cloudflare Turnstile — server-side verification.
3
+ *
4
+ * There is NO official Astro or Workers integration. `@cloudflare/pages-plugin-turnstile` is
5
+ * a Pages Functions plugin and does not work here. Verification is this one POST.
6
+ *
7
+ * TWO KEYS, do not mix them up:
8
+ * SITE key — public. Goes in `vars` in wrangler.jsonc, rendered into the widget markup.
9
+ * SECRET key — never leaves the server. `npx wrangler secret put TURNSTILE_SECRET_KEY`.
10
+ *
11
+ * The route that calls this needs `export const prerender = false`.
12
+ */
13
+
14
+ const SITEVERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
15
+
16
+ /** The field Turnstile injects into the form. */
17
+ export const TURNSTILE_FIELD = 'cf-turnstile-response';
18
+
19
+ /** Give up rather than hold a Worker request open on a hung connection. */
20
+ const TIMEOUT_MS = 10_000;
21
+
22
+ export interface TurnstileResult {
23
+ success: boolean;
24
+ /** Failure reasons — Cloudflare's codes, plus `hostname-mismatch` / `action-mismatch`. */
25
+ errorCodes: string[];
26
+ challengeTs?: string;
27
+ /** The hostname the challenge was actually solved on. */
28
+ hostname?: string;
29
+ /** The action the widget was configured with, if any. */
30
+ action?: string;
31
+ }
32
+
33
+ export interface VerifyOptions {
34
+ /** The widget's SECRET key. */
35
+ secretKey: string;
36
+ /** The token from the `cf-turnstile-response` form field. */
37
+ token: string;
38
+ /**
39
+ * The hostname this submission must have been solved on — pass `Astro.url.hostname`.
40
+ *
41
+ * REQUIRED, and not ceremony. A Turnstile token is bound to the sitekey/secret pair, NOT
42
+ * to a site or a form. If one widget is ever reused across client sites (or one widget
43
+ * lists several hostnames), an attacker solves the challenge once on site A and replays
44
+ * the token at site B's form — siteverify returns success, and without this check nobody
45
+ * notices. Verifying the hostname is what binds the token to this site.
46
+ */
47
+ expectedHostname: string;
48
+ /**
49
+ * The `data-action` on the widget, if set. When provided, a token minted by a different
50
+ * form on the same site is rejected — otherwise a newsletter widget's token replays
51
+ * against the contact form.
52
+ */
53
+ expectedAction?: string;
54
+ /** Visitor IP. Pass `request.headers.get('CF-Connecting-IP')`. */
55
+ remoteIp?: string | null;
56
+ /**
57
+ * Idempotency key for safely retrying ONE submission attempt.
58
+ *
59
+ * Must be freshly generated per attempt (`crypto.randomUUID()`). Never a constant, never
60
+ * derived from user input: Cloudflare returns the CACHED result for a repeated key, which
61
+ * defeats the single-use guarantee (`timeout-or-duplicate`) that stops token replay. A
62
+ * hardcoded value turns one solved challenge into unlimited accepted submissions.
63
+ */
64
+ idempotencyKey?: string;
65
+ }
66
+
67
+ /**
68
+ * Verify a Turnstile token.
69
+ *
70
+ * Returns `success: false` rather than throwing on a failed challenge — a bot submission is
71
+ * an expected outcome, not an exception. It DOES throw if Cloudflare is unreachable, times
72
+ * out, or answers with a non-2xx, because that is a real fault and should not be silently
73
+ * counted as spam.
74
+ *
75
+ * A caught error MUST reject the submission, never admit it. Failing open here hands an
76
+ * attacker a bypass: make the siteverify call fail and every submission sails through.
77
+ *
78
+ * export const prerender = false;
79
+ *
80
+ * const form = await request.formData();
81
+ * let result;
82
+ * try {
83
+ * result = await verifyTurnstile({
84
+ * secretKey: getBinding<string>('TURNSTILE_SECRET_KEY'),
85
+ * token: String(form.get(TURNSTILE_FIELD) ?? ''),
86
+ * expectedHostname: Astro.url.hostname,
87
+ * remoteIp: request.headers.get('CF-Connecting-IP'),
88
+ * });
89
+ * } catch {
90
+ * return new Response('Verification unavailable', { status: 503 }); // reject, not admit
91
+ * }
92
+ * if (!result.success) return new Response('Failed verification', { status: 400 });
93
+ */
94
+ export async function verifyTurnstile(options: VerifyOptions): Promise<TurnstileResult> {
95
+ const { secretKey, token, expectedHostname, expectedAction, remoteIp, idempotencyKey } = options;
96
+
97
+ // An empty token means the widget never solved — no point spending a round trip.
98
+ if (!token) {
99
+ return { success: false, errorCodes: ['missing-input-response'] };
100
+ }
101
+
102
+ const response = await fetch(SITEVERIFY_URL, {
103
+ method: 'POST',
104
+ headers: { 'Content-Type': 'application/json' },
105
+ body: JSON.stringify({
106
+ secret: secretKey,
107
+ response: token,
108
+ ...(remoteIp ? { remoteip: remoteIp } : {}),
109
+ ...(idempotencyKey ? { idempotency_key: idempotencyKey } : {}),
110
+ }),
111
+ signal: AbortSignal.timeout(TIMEOUT_MS),
112
+ });
113
+
114
+ if (!response.ok) {
115
+ throw new Error(
116
+ `[webm] Turnstile siteverify returned ${response.status}. This is an outage or a ` +
117
+ `malformed request, not a failed challenge - reject the submission, do not admit it.`,
118
+ );
119
+ }
120
+
121
+ const data = (await response.json()) as {
122
+ success: boolean;
123
+ 'error-codes'?: string[];
124
+ challenge_ts?: string;
125
+ hostname?: string;
126
+ action?: string;
127
+ };
128
+
129
+ const result: TurnstileResult = {
130
+ success: data.success === true,
131
+ errorCodes: data['error-codes'] ?? [],
132
+ challengeTs: data.challenge_ts,
133
+ hostname: data.hostname,
134
+ action: data.action,
135
+ };
136
+
137
+ if (!result.success) return result;
138
+
139
+ // Cloudflare said the challenge was solved. Now confirm it was solved HERE, for THIS form.
140
+ if (result.hostname !== expectedHostname) {
141
+ return {
142
+ ...result,
143
+ success: false,
144
+ errorCodes: [...result.errorCodes, 'hostname-mismatch'],
145
+ };
146
+ }
147
+
148
+ if (expectedAction !== undefined && result.action !== expectedAction) {
149
+ return {
150
+ ...result,
151
+ success: false,
152
+ errorCodes: [...result.errorCodes, 'action-mismatch'],
153
+ };
154
+ }
155
+
156
+ return result;
157
+ }
@@ -0,0 +1,56 @@
1
+ /*
2
+ * Access to Cloudflare bindings and secrets.
3
+ *
4
+ * THE ONLY CORRECT IMPORT is `cloudflare:workers`. `Astro.locals.runtime` was removed in
5
+ * @astrojs/cloudflare v13 — any snippet using it is Cloudflare-Pages-era and will not work.
6
+ *
7
+ * Bindings are UNAVAILABLE while prerendering. Any route that reads one needs:
8
+ *
9
+ * export const prerender = false;
10
+ */
11
+ import { env } from 'cloudflare:workers';
12
+
13
+ /*
14
+ * `env` is deliberately NOT re-exported.
15
+ *
16
+ * Every secret is a plain string property on it, so a single `{JSON.stringify(env)}` on a
17
+ * debug page — exactly what gets added while chasing a "binding is undefined" problem —
18
+ * dumps every secret into the response. Import it from 'cloudflare:workers' directly if you
19
+ * genuinely need the whole object, or use getBinding() below to take one value at a time.
20
+ */
21
+
22
+ /**
23
+ * Read a binding or secret by name, failing loudly if it is not configured.
24
+ *
25
+ * Bindings you have not declared yet do not exist on the generated `Env` type, so
26
+ * `env.DB` will not typecheck until the binding is in wrangler.jsonc and you have re-run
27
+ * `wrangler types` (which `npm run dev` and `npm run build` do for you). This helper is the
28
+ * bridge: it looks the binding up at runtime and hands back the type you assert.
29
+ *
30
+ * const db = getBinding<D1Database>('DB');
31
+ *
32
+ * Once `wrangler types` knows about the binding, prefer plain `env.DB` — it is genuinely
33
+ * type-checked, whereas this is an assertion you are making.
34
+ */
35
+ export function getBinding<T>(name: string): T {
36
+ const value = (env as unknown as Record<string, unknown>)[name];
37
+
38
+ if (value === undefined || value === null || value === '') {
39
+ throw new Error(
40
+ `[webm] Missing Cloudflare binding or secret "${name}".\n` +
41
+ ` - Local dev: add it to .dev.vars (copy .dev.vars.example)\n` +
42
+ ` - Production: npx wrangler secret put ${name}\n` +
43
+ ` - Resource bindings (D1, KV, R2, Queues) go in wrangler.jsonc, not secrets\n` +
44
+ ` - Reading a binding on a prerendered route always fails; set ` +
45
+ `\`export const prerender = false\` on the route.`,
46
+ );
47
+ }
48
+
49
+ return value as T;
50
+ }
51
+
52
+ /** True if a binding or secret is configured. Use to make a feature degrade rather than throw. */
53
+ export function hasBinding(name: string): boolean {
54
+ const value = (env as unknown as Record<string, unknown>)[name];
55
+ return value !== undefined && value !== null && value !== '';
56
+ }