@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.
- package/CHANGELOG.md +56 -0
- package/LICENSE +21 -0
- package/README.md +104 -0
- package/agents/.gitkeep +0 -0
- package/dist/webm.mjs +2381 -0
- package/hooks/.gitkeep +0 -0
- package/package.json +101 -0
- package/schema/design.json +118 -0
- package/skills/launch/SKILL.md +183 -0
- package/skills/new-component/SKILL.md +85 -0
- package/skills/start/SKILL.md +117 -0
- package/skills/traps/SKILL.md +333 -0
- package/skills/upgrade/SKILL.md +42 -0
- package/src/actions/index.ts +348 -0
- package/src/cli/checks.test.ts +711 -0
- package/src/cli/checks.ts +822 -0
- package/src/cli/codemods.test.ts +42 -0
- package/src/cli/codemods.ts +51 -0
- package/src/cli/compare.test.ts +144 -0
- package/src/cli/compare.ts +222 -0
- package/src/cli/design-extract.test.ts +96 -0
- package/src/cli/design-extract.ts +229 -0
- package/src/cli/doctor.ts +187 -0
- package/src/cli/mcp.test.ts +57 -0
- package/src/cli/mcp.ts +110 -0
- package/src/cli/new.ts +148 -0
- package/src/cli/package-root.ts +58 -0
- package/src/cli/scaffold.test.ts +230 -0
- package/src/cli/scaffold.ts +424 -0
- package/src/cli/seed.ts +133 -0
- package/src/cli/slug.test.ts +70 -0
- package/src/cli/slug.ts +109 -0
- package/src/cli/sync.test.ts +137 -0
- package/src/cli/sync.ts +266 -0
- package/src/cli/upgrade.ts +93 -0
- package/src/design/__fixtures__/tokens-v1.4.1.css +227 -0
- package/src/design/brand.ts +49 -0
- package/src/design/compile.test.ts +98 -0
- package/src/design/compile.ts +155 -0
- package/src/design/defaults.ts +315 -0
- package/src/design/index.ts +18 -0
- package/src/design/resolve.test.ts +80 -0
- package/src/design/resolve.ts +108 -0
- package/src/design/types.ts +96 -0
- package/src/emails/autoresponse.test.ts +82 -0
- package/src/emails/autoresponse.ts +83 -0
- package/src/emails/footer.test.ts +102 -0
- package/src/emails/footer.ts +91 -0
- package/src/emails/index.ts +24 -0
- package/src/emails/subject.test.ts +66 -0
- package/src/emails/subject.ts +80 -0
- package/src/emails/submission-notification.ts +76 -0
- package/src/env.d.ts +12 -0
- package/src/includes/cloudflare/d1/client.ts +86 -0
- package/src/includes/cloudflare/r2/README.md +68 -0
- package/src/includes/cloudflare/r2/media.ts +41 -0
- package/src/includes/cloudflare/r2/url.test.ts +44 -0
- package/src/includes/cloudflare/r2/url.ts +34 -0
- package/src/includes/cloudflare/turnstile/Turnstile.astro +161 -0
- package/src/includes/cloudflare/turnstile/verify.ts +157 -0
- package/src/includes/cloudflare/workers/env.ts +56 -0
- package/src/includes/google/tag-manager/TagManager.astro +73 -0
- package/src/includes/sinch/mailgun/redirect.test.ts +96 -0
- package/src/includes/sinch/mailgun/redirect.ts +96 -0
- package/src/includes/sinch/mailgun/send.ts +186 -0
- package/src/includes/webmonterey/animations/animations.css +347 -0
- package/src/includes/webmonterey/animations/observe.test.ts +88 -0
- package/src/includes/webmonterey/animations/observe.ts +209 -0
- package/src/includes/webmonterey/compliance/ConsentInit.astro +74 -0
- package/src/includes/webmonterey/compliance/CookieConsent.astro +558 -0
- package/src/includes/webmonterey/compliance/consent-styles.test.ts +83 -0
- package/src/includes/webmonterey/compliance/consent.ts +142 -0
- package/src/includes/webmonterey/config.test.ts +94 -0
- package/src/includes/webmonterey/config.ts +346 -0
- package/src/includes/webmonterey/copy-defaults.ts +148 -0
- package/src/includes/webmonterey/copy.ts +13 -0
- package/src/includes/webmonterey/credits/Credit.astro +80 -0
- package/src/includes/webmonterey/credits/credit.test.ts +111 -0
- package/src/includes/webmonterey/credits/credit.ts +59 -0
- package/src/includes/webmonterey/forms/honeypot.test.ts +40 -0
- package/src/includes/webmonterey/forms/honeypot.ts +66 -0
- package/src/includes/webmonterey/prose/inline.test.ts +94 -0
- package/src/includes/webmonterey/prose/inline.ts +71 -0
- package/src/includes/webmonterey/scroll-top/ScrollTop.astro +209 -0
- package/src/includes/webmonterey/site.ts +136 -0
- package/src/includes/webmonterey/structured-data/nodes.ts +315 -0
- package/src/includes/webmonterey/zoned-hour.test.ts +49 -0
- package/src/integration/adapter.ts +53 -0
- package/src/integration/app-middleware.ts +43 -0
- package/src/integration/config.ts +96 -0
- package/src/integration/content.ts +66 -0
- package/src/integration/image-size.test.ts +83 -0
- package/src/integration/image-size.ts +100 -0
- package/src/integration/index.ts +386 -0
- package/src/integration/virtual.d.ts +101 -0
- package/src/layouts/base.astro +402 -0
- package/src/package.test.ts +121 -0
- package/src/pages/404.astro +33 -0
- package/src/pages/[...slug].astro +126 -0
- package/src/pages/robots.txt.ts +41 -0
- package/src/pages/webm.astro +23 -0
- package/src/scripts/.gitkeep +0 -0
- package/src/styles/base.css +185 -0
- package/src/styles/global.css +47 -0
- package/src/styles/layers.test.ts +32 -0
- package/src/styles/layers.ts +21 -0
- package/src/styles/layout.css +235 -0
- package/src/styles/reset.css +135 -0
- package/src/styles/utilities.css +90 -0
- package/src/worker.ts +76 -0
- package/template/assets/logo.svg +14 -0
- package/template/assets/open-graph.png +0 -0
- package/template/migrations/0001_create_submissions.sql +40 -0
- package/template/migrations/README.md +41 -0
- package/template/public/android-chrome-192x192.png +0 -0
- package/template/public/android-chrome-512x512.png +0 -0
- package/template/public/apple-touch-icon.png +0 -0
- package/template/public/favicon-16x16.png +0 -0
- package/template/public/favicon-32x32.png +0 -0
- package/template/public/favicon.ico +0 -0
- package/template/public/favicon.svg +4 -0
- package/template/public/open-graph.png +0 -0
- package/template/scripts/check-node.mjs +72 -0
- package/template/scripts/test-hooks.mjs +96 -0
- package/template/site/.editorconfig +22 -0
- package/template/site/.prettierignore +15 -0
- package/template/site/.prettierrc.json +17 -0
- package/template/site/CLAUDE.md +245 -0
- package/template/site/CONTENT.md +135 -0
- package/template/site/PRIVACY-POLICY-DRAFT.md +81 -0
- package/template/site/public/_headers +70 -0
- package/template/site/src/forms/contact.json +45 -0
- 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
|
+
}
|