Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 0 additions & 6 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,6 @@ VITE_SAHAJCLOUD_URL="https://cloud.sydevelopers.com"
# #130. Set them on every Workers Builds trigger that builds the app; the runbook is in
# docs/environment.md.

# Cloudflare Turnstile site key for the report-issue form. A SITE key is public by
# design (the secret half lives in SahajCloud), so it belongs here rather than in
# .env.local. This is Cloudflare's always-passes TEST key — dev, Ladle and CI use it;
# production overrides it with the real key as a BUILD variable on its Workers Builds
# trigger, and a production build fails if it does not (#259, docs/environment.md).
VITE_TURNSTILE_SITE_KEY="1x00000000000000000000AA"

# Where a FRAMED embed sends a visitor when its frame is too small for the interface (#161).
# A frame cannot expand — `position: fixed` resolves against the frame — so the compact card's
Expand Down
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,20 @@ cover everything a host would notice since the widget was first deployed.

### Changed

- ⚠ **BREAKING: your CSP needs `frame-src https://cloud.sydevelopers.com`, and the three
`challenges.cloudflare.com` entries can go.** ([#343], [SahajCloud#1072]) The captcha that
gates every form now renders in a small frame `cloud.sydevelopers.com` serves — the origin
already in your `connect-src` — instead of in your document. A policy that does not allow the
frame shows the error screen, and the console names the fix.

⚠ **The captcha renders only on a domain your client record lists** — the same list that
already gates the API. Any other domain shows the error screen. An iframe embed needs nothing
listed: there the challenge's parent is the widget's own page.

**Onboarding a site needs no Turnstile step**: Turnstile checks the hostname of the document
that renders the challenge, which is now always `cloud.sydevelopers.com`. No script of
Cloudflare's runs in your document, and your `Permissions-Policy` needs nothing for the captcha.

- **A repeat embed report turns into at most one write in any 6 h window.** ([#160]) The widget
still reports on every page load, and an unchanged report inside that window is answered
without a write. This guide and the embedding guide had both published the window as one hour.
Expand Down Expand Up @@ -844,6 +858,8 @@ must-revalidate`, pinned rather than left to the CDN default. The production dom
[#312]: https://github.com/sydevs/SahajAtlasWeb/pull/312
[#320]: https://github.com/sydevs/SahajAtlasWeb/issues/320
[#326]: https://github.com/sydevs/SahajAtlasWeb/issues/326
[#343]: https://github.com/sydevs/SahajAtlasWeb/issues/343
[SahajCloud#1072]: https://github.com/sydevs/SahajCloud/issues/1072
[#322]: https://github.com/sydevs/SahajAtlasWeb/issues/322
[#329]: https://github.com/sydevs/SahajAtlasWeb/pull/329
[#325]: https://github.com/sydevs/SahajAtlasWeb/issues/325
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,9 @@ SahajCloud's:
- **`react-circle-flags.pages.dev`** serves the country flag SVGs
(`referrer-policy: no-referrer`). **`challenges.cloudflare.com`** loads
Turnstile as soon as the interface mounts, not when a visitor opens a form
(#182). Both are in the CSP table in
(#182) — from a challenge page SahajCloud serves, not from the host's page
(#343), so the host allows that page under `frame-src` instead. Both are in
the CSP table in
[`docs/embedding.md`](docs/embedding.md#content-security-policy).

None of these has a script-URL opt-out (#149) — each is built so it does not
Expand Down
117 changes: 57 additions & 60 deletions docs/embedding.md

Large diffs are not rendered by default.

8 changes: 5 additions & 3 deletions docs/environment.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ place it is stated — everywhere else in this repo just points here.
| --------------------------- | ----------------------------- | ------- |
| `VITE_SAHAJCLOUD_URL` | `.env` | SahajCloud origin. The client appends `/api`. Default `https://cloud.sydevelopers.com`. Local backend: `http://localhost:3000`. |
| `ATLAS_API_KEY` | shell (never committed) | A **production** `sahaj-atlas-client` API key, read by `pnpm sync:translations` to pull the English copy out of SahajCloud. Build-time only, no `VITE_` prefix — it never reaches the bundle. The script falls back to `VITE_SAHAJCLOUD_API_KEY` **only** when `VITE_SAHAJCLOUD_URL` is the production origin: a key minted against a local CMS, sent to `cloud.sydevelopers.com`, lands in production's log as a rejected credential and gets rotated in a hurry. ⚠ It replaced `VITE_HOST` here, which #198 deleted along with `public/locales/` — the widget fetches its copy from SahajCloud now, so there is no second origin to name. |
| `VITE_TURNSTILE_SITE_KEY` | `.env` **+ Cloudflare — Production build variable** | Cloudflare Turnstile **site** key for the registration and report-issue forms. Public by design — the secret half lives in SahajCloud — so this is committed. `.env` holds Cloudflare's always-passes test key `1x00000000000000000000AA` for dev, Ladle, and CI. Production overrides it with the real `0x4AAAA…` key as a **build variable** on the app Worker's production Workers Builds trigger (Workers & Pages → `sahajatlas-web` → Settings → Build → Variables and secrets). ⚠ **A runtime variable (Settings → Variables and Secrets) does nothing here** — Vite inlines the key at build time, and this Worker runs no code. Previews keep the test key on purpose: a real key validates only on the hostnames it lists, and a preview's `workers.dev` host is not one. **A release guard holds this (#259)**: `scripts/turnstile-release-guard.mjs`, a Vite plugin, fails a production build whose resolved key is a test key (any `[123]x0000…`), empty, or not a bare `0x…` key (quotes or spaces pasted around it would reach Turnstile too), naming the variable and this setting. Production means Workers Builds (`WORKERS_CI=1`) building `main`, or naming no branch at all — it fails closed; every other branch, every GitHub Actions build and every local build passes on the test key. ⚠ **It guards a build, so production can change without it**: a manual `wrangler deploy` of a locally built `dist/`, or promoting or rolling back to a version no production build made — and every same-repo PR's smoke job uploads a test-key version of this very Worker (`ci.yml`, `versions upload`), so the dashboard's version list is full of them. `embed.smoke.test.ts` reads the live key after every Workers Builds deploy; after any of the others, run `Production Smoke` by hand. ⚠ It also assumes only the production trigger builds `main`: a preview trigger whose branch rule is `*` would build `main` without the key and go red on every merge. Unset, `probeTurnstile` answers `false` and `useTurnstileGuard` fails the whole widget to the captcha-blocked screen — the same state as a CSP-refused script, and no `mailto:` fallback since #182. **Since #103 this key is load-bearing, not decorative.** The form now sends a real email through SahajCloud's captcha-gated endpoint. In production, the test key then makes that captcha a no-op. A site key that does not pair with SahajCloud's `TURNSTILE_SECRET_KEY` makes every report fail with a 403 `captcha_failed`. The viewer sees only one sentence of text for this. |
| `VITE_SENTRY_DSN` | Cloudflare — **Production + Preview** | Sentry ingest DSN for automatic error reporting (issue #108). Use the **modern public-key form** `https://<key>@<host>/<project>`. The legacy `https://<key>:<secret>@…` form puts a real secret in the public bundle. A DSN is write-only, so it is public by design. This repo still does **not** commit it to `.env`: a committed DSN would send every fork, preview, and `pnpm dev` into the production project. Set it per Workers Builds trigger in the Cloudflare dashboard. This org is in Sentry's **DE region**, so the ingest host is `o…​.ingest.de.sentry.io` — not `…us…`, which is what the DSN's own text will tell you. **The integrator CSP needs no edit for it**: `docs/embedding.md`'s `connect-src` row already carries `*.sentry.io`, wide on purpose so a regional host matches (#123). **Unset, `reportInternalError` only logs to the console** — the SDK chunk is never fetched, and that is the only gate there is: the `error-reporting` embed attribute was removed in #149. **Preview carries one too, and that is load-bearing, not a convenience** (#232): `boot-origins.smoke.test.ts` reads the ingest host back off the deploy, and `assert-preview-host.mjs` refuses the Worker's own production host, so a preview is the only deploy the PR gate can observe. ⚠ Since #282 the preview's copy is an **Actions secret in this repo**, read by `ci.yml`'s publish build — not a Cloudflare Preview-environment variable. The production deploy's stays in the dashboard. |
| `VITE_SENTRY_ENVIRONMENT` | Cloudflare — **Preview** | The `environment` tag on every reported event (#232). It replaced `import.meta.env.MODE`, which reads `production` for **every** `vite build`, preview included — so with a DSN on Preview, preview noise would be indistinguishable from the events that matter. Set it to `preview` there and leave Production alone: unset, a build defaults to `production` and `pnpm dev` to `development`, so nothing gets mislabelled. Not a secret, and not in `.env`, where it would be inert anyway — nothing reports without a DSN. |
| `SENTRY_AUTH_TOKEN` | Cloudflare | **Build-time only, and a real secret** (issue #130). It carries no `VITE_` prefix, so Vite cannot inline it. **`pnpm assert:maps` also greps every emitted file for its value** and fails the build if it appears — a gate, not a habit. Its presence switches source-map upload on. Set: `build.sourcemap: 'hidden'`, and `@sentry/vite-plugin` uploads the maps, then deletes them. Unset: **no maps are emitted at all**, and the build matches one from before #130 byte-for-byte. See runbook step 2 for the token scopes it needs. The plugin never creates, finalizes, or sets commits on a release. Still, the upload call stays release-scoped. |
Expand All @@ -31,6 +30,9 @@ place it is stated — everywhere else in this repo just points here.
| `VITE_FATHOM_ID` | `.env.local` | Fathom analytics site id (optional). Analytics is disabled when unset or on localhost. |
| `SAHAJCLOUD_DOCS_PASSWORD` | `.env.local` | HTTP basic-auth password for the SahajCloud OpenAPI docs. Used **only** by the `pnpm types:openapi` script. Never `VITE_`-prefixed — tooling-only, never in the bundle. |

**The widget carries no Turnstile configuration.** Its captcha renders in a page SahajCloud serves
(`GET /api/atlas/challenge`, sydevs/SahajCloud#1072), which holds both halves of the key.

## GitHub Actions secrets — the smoke lane's preview (#282)

The `Smoke` job builds the app and publishes its own Workers preview, so five values live in
Expand All @@ -56,8 +58,8 @@ deploy. That is deliberate: the alternative is a green check that read nothing,
which is the hole #99 closed.

`VITE_SENTRY_ENVIRONMENT: preview` is a plain workflow value in `ci.yml`, not a secret — unset, a
build tags itself `production`. `VITE_SAHAJCLOUD_URL` and `VITE_TURNSTILE_SITE_KEY` keep `.env`'s
committed defaults, the Turnstile one being the always-passes test key. `SENTRY_AUTH_TOKEN` is
build tags itself `production`. `VITE_SAHAJCLOUD_URL` keeps `.env`'s committed default.
`SENTRY_AUTH_TOKEN` is
deliberately **not** set for a preview: no maps are emitted at all and `pnpm assert:maps` passes,
which is the simplest correct state.

Expand Down
10 changes: 0 additions & 10 deletions docs/rules/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,6 @@ paths:
- 'scripts/assert-no-sourcemaps.test.ts'
- 'scripts/deploy-headers.test.ts'
- 'scripts/wrangler-config.test.ts'
- 'scripts/turnstile-release-guard.mjs'
- 'scripts/turnstile-release-guard.test.ts'
- '.github/workflows/*.yml'
---

Expand Down Expand Up @@ -114,14 +112,6 @@ Three facts to remember:
The Sentry variables are dashboard-only, and owed to **each** target that builds the app. The
runbook is in `docs/environment.md`.

**A production build refuses Cloudflare's test Turnstile site key (#259).** The real
`VITE_TURNSTILE_SITE_KEY` is a build variable on the production trigger only; `.env`'s test key
is correct everywhere else. `scripts/turnstile-release-guard.mjs` fails a Workers Builds build of
`main` whose resolved key is a test key, empty or malformed, so removing that variable fails the
release instead of shipping a captcha that accepts everyone. `embed.smoke.test.ts` checks the live
bundle after each Workers Builds deploy. ⚠ Neither sees a version promoted or rolled back without a
build. What counts as production, and that gap: `docs/environment.md`.

## Deploy-side CI gates

Two of the four CI gates that exist because each one once missed something, unnoticed (#99).
Expand Down
2 changes: 1 addition & 1 deletion docs/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,7 @@ alert. The lane's last step opens an issue instead, and three things about it ar
production read, and a ticket claiming production is red when nothing read it is worse than no
ticket. `ci-workflows.test.ts` pins that gate and the token, since both fail silently.

**One spec is scoped by environment, on purpose** (#259): `embed.smoke.test.ts`'s Turnstile case expects the committed test site key on an address `assert-preview-host.mjs` accepts as the app Worker's preview, and a real `0x4…` key with no test key everywhere else — so a bypassed build gate still reddens production. The preview half keeps the production half from passing vacuously: the same patterns must find the test key there. Measured 2026-10-30: green on `sahajatlas.com` and on `pr-344`'s preview, red against a locally served build carrying the test key and against one carrying no key. Every other spec reads both hosts alike. The lane is **18 cases**, from 14 declarations — count it rather
Every spec reads both hosts alike. The lane is **17 cases**, from 13 declarations — count it rather
than trusting a figure, since `.each` makes the two differ. Measured 2026-10-06 against the 16 it
was then: all 16 passed against `sahajatlas.com`. Re-measured 2026-10-13 against the playground
on its Worker (`PREVIEW_URL=https://design.sahajatlas.com pnpm test:smoke`, #279): **10 of the 17
Expand Down
156 changes: 0 additions & 156 deletions scripts/turnstile-release-guard.mjs

This file was deleted.

Loading
Loading