diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index d8a681d..372d2e4 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -65,6 +65,7 @@ jobs: TWILIO_FROM_NUMBER: ${{ secrets.TWILIO_FROM_NUMBER }} TWILIO_VERIFY_SERVICE_SID: ${{ secrets.TWILIO_VERIFY_SERVICE_SID }} TURNSTILE_SECRET_KEY: ${{ secrets.TURNSTILE_SECRET_KEY }} + TURNSTILE_SITE_KEY: ${{ vars.TURNSTILE_SITE_KEY }} steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 diff --git a/package.json b/package.json index 9559533..aa1bf20 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,7 @@ "private": true, "type": "module", "scripts": { - "lint": "node --check tool-registry/worker.js && node --check workers/permit-poller/worker.js && node --check workers/permit-poller/catalog.js && node --check workers/permit-poller/adapters.js && node --check workers/permit-poller/service.js && node --check lib/registry.js && node --check lib/queries.js && node --check lib/mcp-server.js && node --check lib/resources.js && node --check scripts/live-acceptance.mjs && node --check scripts/permit-poller-live-check.mjs && node --check scripts/deploy-permit-poller.mjs", + "lint": "node --check tool-registry/worker.js && node --check workers/permit-poller/worker.js && node --check workers/permit-poller/public-site.js && node --check workers/permit-poller/catalog.js && node --check workers/permit-poller/adapters.js && node --check workers/permit-poller/service.js && node --check lib/registry.js && node --check lib/queries.js && node --check lib/mcp-server.js && node --check lib/resources.js && node --check scripts/live-acceptance.mjs && node --check scripts/permit-poller-live-check.mjs && node --check scripts/deploy-permit-poller.mjs", "test": "node --test tests/registry.test.mjs", "test:workers": "vitest run --config vitest.config.mjs", "test:permit-poller": "node --test tests/permit-poller.test.mjs && vitest run --config vitest.permit-poller.config.mjs", diff --git a/scripts/deploy-permit-poller.mjs b/scripts/deploy-permit-poller.mjs index 7f0d62c..a018157 100644 --- a/scripts/deploy-permit-poller.mjs +++ b/scripts/deploy-permit-poller.mjs @@ -27,6 +27,9 @@ const missingSecrets = secretNames.filter((name) => !process.env[name]); if (missingSecrets.length) { throw new Error(`Missing required deployment secrets: ${missingSecrets.join(", ")}`); } +if (!process.env.TURNSTILE_SITE_KEY) { + throw new Error("Missing required deployment variable: TURNSTILE_SITE_KEY"); +} const wrangler = (...args) => execFileSync("npx", ["wrangler", ...args], { @@ -91,6 +94,7 @@ for (const queueName of queueNames) { const config = JSON.parse(readFileSync(sourceConfigPath, "utf8")); config.d1_databases[0].database_id = databaseId; +config.vars.TURNSTILE_SITE_KEY = process.env.TURNSTILE_SITE_KEY; writeFileSync(deployConfigPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 }); try { diff --git a/tests/permit-poller.test.mjs b/tests/permit-poller.test.mjs index 0d0d949..7cf5503 100644 --- a/tests/permit-poller.test.mjs +++ b/tests/permit-poller.test.mjs @@ -134,3 +134,18 @@ test("HTTP catalog is no-store and rejects unapproved browser origins", async () }), env); assert.equal(bad.status, 403); }); + +test("public signup and compliance pages are served by the alerts Worker", async () => { + const env = { ALLOWED_ORIGINS: "https://alerts.trailgenic.com", TURNSTILE_SITE_KEY: "0x_test" }; + const signup = await handleHttp(new Request("https://alerts.trailgenic.com/"), env); + assert.equal(signup.status, 200); + const signupBody = await signup.text(); + assert.match(signupBody, /TrailGenic Permit Alert SMS messages/); + assert.match(signupBody, /0x_test/); + + for (const path of ["/privacy", "/terms", "/help"]) { + const response = await handleHttp(new Request(`https://alerts.trailgenic.com${path}`), env); + assert.equal(response.status, 200); + assert.equal(response.headers.get("Content-Type"), "text/html; charset=utf-8"); + } +}); diff --git a/workers/permit-poller/public-site.js b/workers/permit-poller/public-site.js new file mode 100644 index 0000000..15e24b2 --- /dev/null +++ b/workers/permit-poller/public-site.js @@ -0,0 +1,63 @@ +const HTML_TYPE = "text/html; charset=utf-8"; + +const escapeHtml = (value = "") => String(value) + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); + +const shell = ({ title, body, siteKey = "", script = "" }) => ` + +
+ + + +You’ll receive a text only when a requested permit date has enough availability for your selected party size.
Reply STOP to any alert to opt out. Reply HELP for help.
'; show("Phone verified—your alert is active.", "success"); } catch (error) { show(error.message); } }); +})();`; + +export const publicPage = (pathname, env) => { + if (pathname === "/") { + return shell({ + title: "Permit Alerts", + siteKey: env.TURNSTILE_SITE_KEY || "", + body: `TrailGenic watches selected permit dates and texts you when enough inventory opens for your group.
Enter the code we texted you to activate your alert.
Availability is monitored information, not a reservation. TrailGenic does not book permits and cannot guarantee inventory will remain available.
`, + script: signupScript + }); + } + if (pathname === "/privacy") return shell({ title: "Privacy Policy", body: `Effective July 19, 2026
When you request a permit alert, TrailGenic collects your mobile number, selected permit product, requested date or dates, party size, phone-verification status, and basic service records needed to operate the alert.
We use this information to verify your phone, monitor the dates you selected, send availability alerts, prevent abuse, and operate and improve the service. We do not use your number to send unrelated marketing messages through this permit-alert service.
We use Cloudflare to host and protect the service and Twilio to verify phone numbers and deliver SMS. These providers process information only to provide their services to TrailGenic.
You can opt out of alert messages by replying STOP. We retain records as reasonably necessary to operate, secure, and comply with our obligations for the service. To ask about your information, use the contact information on trailgenic.com.
We may update this policy as the service develops. The effective date above reflects the latest version.
` }); + if (pathname === "/terms") return shell({ title: "Terms & Conditions", body: `Effective July 19, 2026
TrailGenic monitors selected permit inventory and sends SMS alerts when availability appears to meet your requested party size. TrailGenic is independent from Recreation.gov and public-land agencies. We do not make reservations and do not guarantee availability, timing, or successful booking.
By checking the consent box and verifying your phone, you agree to receive recurring automated TrailGenic Permit Alert SMS messages about the permit selections you submit. Message frequency varies based on availability. Message and data rates may apply. Consent is not a condition of purchase.
Reply STOP to any alert to opt out. Reply HELP for help or visit alerts.trailgenic.com/help. Carriers are not liable for delayed or undelivered messages.
Submit only your own phone number, keep your permit preferences accurate, and review availability promptly through the official booking provider. Do not use the service to interfere with booking systems or circumvent agency rules.
We may change or discontinue the service. Questions about these terms can be sent through the contact information on trailgenic.com.
` }); + if (pathname === "/help") return shell({ title: "Help", body: `Need help with a TrailGenic permit alert?
TrailGenic cannot change or book a permit for you. Use the official booking link in your alert to review and reserve available inventory.
` }); + return null; +}; diff --git a/workers/permit-poller/worker.js b/workers/permit-poller/worker.js index da13b1c..7613620 100644 --- a/workers/permit-poller/worker.js +++ b/workers/permit-poller/worker.js @@ -11,6 +11,7 @@ import { verifyTrackerPhone, validateTrackerInput } from "./service.js"; +import { publicPage } from "./public-site.js"; const JSON_TYPE = "application/json; charset=utf-8"; @@ -80,6 +81,20 @@ const handleHttp = async (request, env) => { return new Response(null, { status: 204, headers: corsHeaders(request, env) }); } + if (request.method === "GET") { + const page = publicPage(url.pathname, env); + if (page) { + return new Response(page, { + headers: { + "Content-Type": "text/html; charset=utf-8", + "Cache-Control": "no-store", + "X-Content-Type-Options": "nosniff", + "Referrer-Policy": "strict-origin-when-cross-origin" + } + }); + } + } + if (request.method === "GET" && url.pathname === "/health") { try { const health = await healthSnapshot(env); diff --git a/workers/permit-poller/wrangler.jsonc b/workers/permit-poller/wrangler.jsonc index a598d0c..c21301c 100644 --- a/workers/permit-poller/wrangler.jsonc +++ b/workers/permit-poller/wrangler.jsonc @@ -53,7 +53,7 @@ ] }, "vars": { - "ALLOWED_ORIGINS": "https://trailgenic.com,https://www.trailgenic.com", + "ALLOWED_ORIGINS": "https://trailgenic.com,https://www.trailgenic.com,https://alerts.trailgenic.com", "REQUIRE_TURNSTILE": "true" } }