@reblu/site-next 1.5.1 → 1.6.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @reblu/site-next
2
2
 
3
- Next.js glue for Reblu SITEs (REB-418, REB-510). Four responsibilities, all
3
+ Next.js glue for Reblu SITEs (REB-418, REB-510). Five responsibilities, all
4
4
  intentionally Next-coupled so the lower SDK layers —
5
5
  [`@reblu/site-client`](../site-client), [`@reblu/site-contracts`](../site-contracts),
6
6
  [`@reblu/site-ui`](../site-ui) — stay framework-agnostic:
@@ -15,6 +15,9 @@ intentionally Next-coupled so the lower SDK layers —
15
15
  factory that proxies them to base-api's `/api/site/auth/*`.
16
16
  4. **`<JsonLd>`** (`./json-ld`) — safe `application/ld+json` `<script>`
17
17
  rendering; see "Structured data (JSON-LD)" below.
18
+ 5. **reCAPTCHA v3** (`./recaptcha`) — the SITE's half of the contact-form
19
+ captcha contract: the canonical action, the render gate, and the
20
+ interaction-mount key. See "reCAPTCHA v3" below.
18
21
 
19
22
  ## Install
20
23
 
@@ -158,6 +161,101 @@ must never drag in `CtaButtons`/`EditableCta`'s client-component graph.
158
161
  Measured: `dist/json-ld.js` is 701 B (451 B gzipped) and its only runtime
159
162
  import is `react/jsx-runtime` — no parser, no sanitiser chain.
160
163
 
164
+ ## reCAPTCHA v3
165
+
166
+ `@reblu/site-next/recaptcha` (REB-588, zero dependencies, no `'use client'`) is
167
+ the SITE's half of the captcha contract. It does **not** load Google's script
168
+ and does **not** verify tokens — it makes the three decisions every SITE was
169
+ otherwise making alone and differently.
170
+
171
+ ### Which half lives where
172
+
173
+ | | Where | Who holds it |
174
+ | --- | --- | --- |
175
+ | **Site key** | `/api/site/analytics` → the browser | Public by design. This module. |
176
+ | **Secret** | `site_integration_config.recaptchaSecretKey`, read only by base-api | **Never leaves base-api.** Not in this package, not in any `@reblu/site-*` package, not in the browser. |
177
+ | **Verification** | `POST /api/site/contact-form` → Google `siteverify` | base-api, per tenant. |
178
+
179
+ The SITE mints a token and posts it as `ContactFormInput.captcha`; base-api
180
+ decides. A SITE holding the secret would be a SITE that could be tricked into
181
+ approving its own tokens, and a distributable SDK is the worst possible place to
182
+ put a credential. **Nothing here calls `siteverify`.**
183
+
184
+ ### Should this form render at all?
185
+
186
+ ```tsx
187
+ import {
188
+ isContactFormAvailable,
189
+ resolveContactFormAvailability,
190
+ } from '@reblu/site-next/recaptcha'
191
+
192
+ const availability = resolveContactFormAvailability(analytics)
193
+ if (!isContactFormAvailable(availability)) return <ContactDetailsOnly />
194
+
195
+ // availability.recaptchaSiteKey is a confirmed, usable key
196
+ ```
197
+
198
+ An **allow-list** (`DB_ONLY`, `WEBHOOK`, `RESEND`), never `mode !== 'DISABLED'`,
199
+ held to the same members in the same order as base-api's own list by a drift
200
+ guard (`apps/base-api/tests/unit/lib/contracts/contact-form-mode-drift.test.ts`).
201
+ The deny-list version is a real incident: it rendered a form for
202
+ `NOT_CONFIGURED` too — which is what every freshly provisioned tenant carries
203
+ until someone finishes the wizard (REB-586) — so a visitor typed out a medical
204
+ enquiry, pressed send, and got `503 Contact form is disabled`. They learned the
205
+ clinic could not be reached only after writing to it. An unknown mode, a mode
206
+ from a base-api newer than your SDK, and a config read that threw all resolve to
207
+ the same answer: no form. The page still shows the phone and the address.
208
+
209
+ The key is checked too, because `ContactFormInput.captcha` is required: an
210
+ accepting mode with no usable key is a form whose every submission is dead on
211
+ arrival. `missing-recaptcha-key` and `malformed-recaptcha-key` are separate
212
+ reasons on purpose — one owner never filled the field, the other filled it with
213
+ the wrong thing, and as of REB-588 **nothing validates that column** in nexus, on
214
+ the way out of `/api/site/analytics`, or in the response contract.
215
+
216
+ ### Load Google only when someone actually uses the form
217
+
218
+ ```tsx
219
+ import { RECAPTCHA_ACTION, deferredRecaptchaKey } from '@reblu/site-next/recaptcha'
220
+
221
+ const [armed, setArmed] = useState(false)
222
+
223
+ <GoogleReCaptchaProvider reCaptchaKey={deferredRecaptchaKey(siteKey, armed)}>
224
+ <form onFocusCapture={() => setArmed(true)}>…</form>
225
+ </GoogleReCaptchaProvider>
226
+
227
+ // at submit: await executeRecaptcha(RECAPTCHA_ACTION)
228
+ ```
229
+
230
+ `GoogleReCaptchaProvider` loads `api.js` the moment it mounts, so mounting it
231
+ with the form means every visitor who merely lands on the page to read the phone
232
+ number has contacted Google. `deferredRecaptchaKey` returns `''` until `armed`,
233
+ and an empty key mounts the provider while injecting nothing; swapping the real
234
+ key in re-runs the library's effect and injects it then, **without remounting
235
+ the children** — so the visitor never loses focus or a half-typed word.
236
+
237
+ Both of those are claims about the vendor's code, so they are pinned by
238
+ `tests/recaptcha-deferred-oracle.test.tsx`, which drives the real
239
+ `react-google-recaptcha-v3` and counts script tags in the DOM. If a future
240
+ version injects eagerly, that test goes red rather than this README quietly
241
+ becoming false.
242
+
243
+ **This is not cookie consent.** Deferring the script narrows the exposure; it
244
+ does not gate it on a lawful basis. That is REB-642.
245
+
246
+ `RECAPTCHA_ACTION` is exported because reCAPTCHA v3 scores **per action**: two
247
+ SITEs calling `executeRecaptcha('submit_form')` and `'contact_form'` build two
248
+ unrelated score histories for the same feature.
249
+
250
+ It is **not a security control.** base-api's `serverCaptchaVerify` checks only
251
+ `success` and `score`, and never reads the `action` Google returns — so a token
252
+ minted for a different action on the same site verifies exactly as well. Binding
253
+ the token to this form needs base-api to assert `action === RECAPTCHA_ACTION`;
254
+ until it does, treat the constant as consistency, not enforcement.
255
+
256
+ `react-google-recaptcha-v3` stays **your** dependency — the subpath imports
257
+ nothing at all, guarded by `tests/recaptcha-subpath-isolation.test.ts`.
258
+
161
259
  ## Build
162
260
 
163
261
  `tsup` → dual ESM/CJS + `.d.ts`. Code-splitting is **off** so each entry keeps its
@@ -0,0 +1,75 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/recaptcha.ts
21
+ var recaptcha_exports = {};
22
+ __export(recaptcha_exports, {
23
+ CONTACT_FORM_OPERATIONAL_MODES: () => CONTACT_FORM_OPERATIONAL_MODES,
24
+ RECAPTCHA_ACTION: () => RECAPTCHA_ACTION,
25
+ RECAPTCHA_SITE_KEY_PATTERN: () => RECAPTCHA_SITE_KEY_PATTERN,
26
+ deferredRecaptchaKey: () => deferredRecaptchaKey,
27
+ isContactFormAvailable: () => isContactFormAvailable,
28
+ isSafeRecaptchaSiteKey: () => isSafeRecaptchaSiteKey,
29
+ resolveContactFormAvailability: () => resolveContactFormAvailability
30
+ });
31
+ module.exports = __toCommonJS(recaptcha_exports);
32
+ var RECAPTCHA_ACTION = "contact_form";
33
+ var RECAPTCHA_SITE_KEY_PATTERN = /^[A-Za-z0-9_-]{16,128}$/;
34
+ function isSafeRecaptchaSiteKey(value) {
35
+ return typeof value === "string" && RECAPTCHA_SITE_KEY_PATTERN.test(value);
36
+ }
37
+ var CONTACT_FORM_OPERATIONAL_MODES = ["DB_ONLY", "WEBHOOK", "RESEND"];
38
+ function trimmed(value) {
39
+ if (typeof value !== "string") return null;
40
+ const text = value.trim();
41
+ return text.length > 0 ? text : null;
42
+ }
43
+ function isOperationalMode(mode) {
44
+ return CONTACT_FORM_OPERATIONAL_MODES.includes(mode);
45
+ }
46
+ function resolveContactFormAvailability(analytics) {
47
+ if (!analytics) return { state: "unavailable", reason: "unreadable" };
48
+ const mode = analytics.contactFormMode;
49
+ if (!isOperationalMode(mode)) return { state: "unavailable", reason: "mode-not-operational" };
50
+ const recaptchaSiteKey = trimmed(analytics.recaptchaSiteKey);
51
+ if (!recaptchaSiteKey) return { state: "unavailable", reason: "missing-recaptcha-key" };
52
+ if (!isSafeRecaptchaSiteKey(recaptchaSiteKey)) {
53
+ return { state: "unavailable", reason: "malformed-recaptcha-key" };
54
+ }
55
+ return { state: "available", mode, recaptchaSiteKey };
56
+ }
57
+ function isContactFormAvailable(availability) {
58
+ return availability.state === "available";
59
+ }
60
+ function deferredRecaptchaKey(siteKey, armed) {
61
+ if (!armed) return "";
62
+ const key = trimmed(siteKey);
63
+ return key && isSafeRecaptchaSiteKey(key) ? key : "";
64
+ }
65
+ // Annotate the CommonJS export names for ESM import in node:
66
+ 0 && (module.exports = {
67
+ CONTACT_FORM_OPERATIONAL_MODES,
68
+ RECAPTCHA_ACTION,
69
+ RECAPTCHA_SITE_KEY_PATTERN,
70
+ deferredRecaptchaKey,
71
+ isContactFormAvailable,
72
+ isSafeRecaptchaSiteKey,
73
+ resolveContactFormAvailability
74
+ });
75
+ //# sourceMappingURL=recaptcha.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/recaptcha.ts"],"sourcesContent":["/**\n * reCAPTCHA v3 — the SITE's half of the contract (REB-588).\n *\n * ─────────────────────────────────────────────────────────────────────────────\n * WHICH HALF LIVES WHERE, AND WHY\n * ─────────────────────────────────────────────────────────────────────────────\n *\n * reCAPTCHA is two secrets and two steps, and they are deliberately split across\n * a trust boundary:\n *\n * SITE KEY — public by design. It ships to the browser inside the script URL,\n * so the tenant serves it from `/api/site/analytics`. This module\n * handles it.\n * SECRET — never leaves base-api. It is stored per tenant in\n * `site_integration_config.recaptchaSecretKey` and read only by\n * `serverCaptchaVerify` when `POST /api/site/contact-form` runs.\n *\n * **Nothing in this module, in this package, or anywhere in the `@reblu/site-*`\n * SDK ever sees the secret, and nothing here calls Google's `siteverify`.** A\n * SITE holding the secret would be a SITE that could be tricked into approving\n * its own tokens, and a distributable SDK is the worst possible place to put a\n * credential. Verification is base-api's job and already works: the SITE mints a\n * token, posts it as `ContactFormInput.captcha`, and base-api decides.\n *\n * That is also why this module is NOT \"reCAPTCHA support\" in the round. It is\n * the three decisions every SITE was otherwise making alone and differently:\n * which action to name, whether a form may be shown at all, and when Google's\n * script is allowed to load.\n *\n * ─────────────────────────────────────────────────────────────────────────────\n * WHY IT IS IN THE SDK AT ALL\n * ─────────────────────────────────────────────────────────────────────────────\n *\n * Because it was already being written four times. `apps/base-api`,\n * `apps/demo-site` and the external `reblu-site-dermamedicin` each grew their\n * own copy of this flow, and they had already drifted — different action\n * strings, different mount timing, a retry loop in one of them. A score that\n * reCAPTCHA reports per action is not comparable across SITEs that name the\n * action differently, and a defect fixed in one copy stays live in the other\n * two. The SDK is where a decision gets made once.\n *\n * This module has ZERO runtime dependencies and imports nothing — not React, not\n * `next`, not `react-google-recaptcha-v3`, not `@reblu/site-ui`. It is pure\n * functions over plain data, callable from a Server Component deciding whether\n * to render a form and from the Client Component that renders it. The subpath is\n * kept dependency-free by `tests/recaptcha-subpath-isolation.test.ts`.\n */\n\nimport type { Analytics, ContactFormMode } from '@reblu/site-contracts'\n\n/**\n * The action name every Reblu SITE mints its contact-form token under.\n *\n * reCAPTCHA v3 scores PER ACTION, and a score is only interpretable against a\n * stable one: Google's own admin console charts each action separately, and a\n * threshold tuned on one action says nothing about another. Two SITEs calling\n * `executeRecaptcha('submit_form')` and `executeRecaptcha('contact_form')` are\n * building two unrelated score histories for the same feature — which is exactly\n * the state the repo was in before this constant existed.\n *\n * `contact_form` over `submit_form` because it names the form, not the verb;\n * a second protected form would need its own action, and `submit_form` would\n * have nothing left to call it.\n *\n * ── WHAT THIS CONSTANT DOES **NOT** BUY, STATED PLAINLY ──\n *\n * It buys comparable scores. It does NOT bind a token to this form, because\n * `serverCaptchaVerify` in base-api checks only `success` and `score`, and never\n * reads the `action` field Google returns. A token minted for any other action\n * on the same site verifies here exactly as well. Making the action load-bearing\n * requires base-api to assert `action === RECAPTCHA_ACTION` on verify; until it\n * does, do not read this constant as a security control.\n */\nexport const RECAPTCHA_ACTION = 'contact_form'\n\n/**\n * The shape a site key must have before this SDK will hand it to Google.\n *\n * ── WHAT THIS IS, AND EMPHATICALLY WHAT IT IS NOT ──\n *\n * It is a SINK-SAFETY guard, not a guess at Google's key format. The key is\n * interpolated into the loader's script URL as a query parameter, so what\n * matters here is that it cannot carry a space, a quote, a newline, an `&`, or a\n * `#` — characters that make a URL mean something other than what was intended.\n * The base64url alphabet Google actually issues from (`A-Z a-z 0-9 - _`) is a\n * strict subset of \"safe\", so a real key passes by construction.\n *\n * The length bounds are deliberately far wider than any key Google issues\n * (theirs are 40 characters). A bound tight enough to be a real format check\n * would be a bound tight enough to take a WORKING tenant's contact form offline\n * the day Google changes its key length — which is strictly worse than the\n * malformed key it was meant to catch. If you are tempted to tighten this,\n * the thing to tighten is the nexus write path, where an owner can still see\n * and fix the error, not this read path where they cannot.\n *\n * ── WHY THE SDK RE-VALIDATES AT ALL ──\n *\n * Same reason `@reblu/site-next/analytics` re-checks `gaId` and `clarityId`\n * (REB-617): base-api is not the only thing a SITE ever talks to, and the layer\n * that owns the value is not the layer that suffers from it being wrong. As of\n * REB-588 this column has NO validation anywhere — not in the nexus form, not on\n * the way out of `/api/site/analytics`, not in the response contract, which is\n * the one asymmetry `gaId`/`clarityId` do not share. Until that is fixed\n * upstream, this is the only guard between a mistyped key and a contact form\n * that renders, looks live, and silently eats every enquiry.\n */\nexport const RECAPTCHA_SITE_KEY_PATTERN = /^[A-Za-z0-9_-]{16,128}$/\n\n/**\n * Whether `value` is a site key safe to put in the loader's script URL.\n *\n * Takes `unknown` on purpose: the value arrives over HTTP, so the contract's\n * `string | null` is a promise about the shape, not a guarantee about the bytes.\n */\nexport function isSafeRecaptchaSiteKey(value: unknown): value is string {\n return typeof value === 'string' && RECAPTCHA_SITE_KEY_PATTERN.test(value)\n}\n\n/**\n * The modes in which base-api will actually accept a submission.\n *\n * An ALLOW-LIST, never `mode !== 'DISABLED'`. The three differ only in what\n * base-api DOES with the payload — a row, a webhook, an email — and the SITE\n * must not care and must never learn the destination: a webhook URL or a Resend\n * key is a tenant secret with no business in a public site.\n *\n * The deny-list version of this is a real incident, not a hypothetical: a page\n * that hid the form on `DISABLED` and rendered it otherwise rendered it for\n * `NOT_CONFIGURED` too — which is what EVERY freshly provisioned tenant carries\n * until someone finishes the wizard (REB-586). A visitor typed out a medical\n * enquiry, pressed send, and got `503 Contact form is disabled`. They learned\n * the clinic could not be reached only after writing to it.\n */\nexport const CONTACT_FORM_OPERATIONAL_MODES = ['DB_ONLY', 'WEBHOOK', 'RESEND'] as const\n\n/** A mode in which the form may be shown. */\nexport type ContactFormOperationalMode = (typeof CONTACT_FORM_OPERATIONAL_MODES)[number]\n\n/**\n * The modes deliberately declined. A TYPE, never a value: the runtime must never\n * read it, because an allow-list must not be expressible as \"everything except\n * this list\", and this subpath's defining property is that it ships nothing it\n * does not need. It exists only to feed {@link ContactFormModeExhaustiveCheck}.\n */\ntype DeclinedMode = 'NOT_CONFIGURED' | 'DISABLED'\n\n/**\n * Compile-time exhaustiveness guard over `ContactFormMode`.\n *\n * `Missing` is constrained to `never` and DEFAULTS to the contract modes that\n * are neither accepted nor explicitly declined, so a default violating its own\n * constraint breaks the build the moment `@reblu/site-contracts` gains a mode.\n *\n * At runtime a new mode already fails closed, which is correct but SILENT — and\n * silence is how a tenant switches their form on in nexus and watches nothing\n * appear. This forces a human to make the call in a diff instead.\n *\n * Exported so it is never reported as an unused symbol.\n */\nexport type ContactFormModeExhaustiveCheck<\n Missing extends never = Exclude<ContactFormMode, ContactFormOperationalMode | DeclinedMode>,\n> = Missing\n\n/** Why the form is not being shown. Drives diagnostics, never visitor-facing copy. */\nexport type ContactFormUnavailableReason =\n /** The analytics read failed or was never made — no answer at all. */\n | 'unreadable'\n /** The tenant's mode is not one base-api accepts submissions in. */\n | 'mode-not-operational'\n /** Accepting mode, but no site key: the browser cannot mint a token. */\n | 'missing-recaptcha-key'\n /** A site key is present but cannot be a key — see {@link RECAPTCHA_SITE_KEY_PATTERN}. */\n | 'malformed-recaptcha-key'\n\nexport type ContactFormAvailability =\n /**\n * Confirmed: base-api accepts submissions AND a usable site key exists. The\n * key travels inside the `available` case on purpose — a component that has\n * proven the form may render should not have to re-read (and re-null-check)\n * the payload to find out what to render it WITH.\n */\n | { state: 'available'; mode: ContactFormOperationalMode; recaptchaSiteKey: string }\n | { state: 'unavailable'; reason: ContactFormUnavailableReason }\n\n/** Whitespace-only is absent — a key someone saved blank is not a key. */\nfunction trimmed(value: unknown): string | null {\n if (typeof value !== 'string') return null\n const text = value.trim()\n return text.length > 0 ? text : null\n}\n\nfunction isOperationalMode(mode: unknown): mode is ContactFormOperationalMode {\n // Widened to `unknown` deliberately: see isSafeRecaptchaSiteKey. A base-api\n // newer than this SDK sends a string TypeScript has never heard of, and it\n // must fail closed rather than be assumed accepting.\n return (CONTACT_FORM_OPERATIONAL_MODES as readonly string[]).includes(mode as string)\n}\n\n/**\n * Resolve whether a contact form may be rendered for this tenant.\n *\n * We offer what we can CONFIRM, never what we have merely failed to disprove: an\n * unknown mode, a mode from a runtime newer than this build, and a config read\n * that threw all resolve to the same answer — no form. The page still shows the\n * phone, the email and the address, so a hidden form costs a visitor one extra\n * glance; a form that eats their message costs them the message.\n *\n * Order matters. The mode is checked BEFORE the key, because \"the tenant has not\n * switched the form on\" is the actionable half of a double failure — a site key\n * for a form nobody enabled is not the problem to fix first.\n */\nexport function resolveContactFormAvailability(\n analytics: Analytics | null | undefined,\n): ContactFormAvailability {\n if (!analytics) return { state: 'unavailable', reason: 'unreadable' }\n\n const mode = analytics.contactFormMode\n if (!isOperationalMode(mode)) return { state: 'unavailable', reason: 'mode-not-operational' }\n\n // `ContactFormInput.captcha` is REQUIRED and base-api verifies it, so an\n // accepting mode with no usable key is still a form whose every submission is\n // dead on arrival. \"Missing\" and \"malformed\" are separated because they call\n // for different fixes by different people: one owner never filled the field,\n // the other filled it with the wrong thing.\n const recaptchaSiteKey = trimmed(analytics.recaptchaSiteKey)\n if (!recaptchaSiteKey) return { state: 'unavailable', reason: 'missing-recaptcha-key' }\n if (!isSafeRecaptchaSiteKey(recaptchaSiteKey)) {\n return { state: 'unavailable', reason: 'malformed-recaptcha-key' }\n }\n\n return { state: 'available', mode, recaptchaSiteKey }\n}\n\n/** Narrowing helper — the single question a renderer should ask. */\nexport function isContactFormAvailable(\n availability: ContactFormAvailability,\n): availability is Extract<ContactFormAvailability, { state: 'available' }> {\n return availability.state === 'available'\n}\n\n/**\n * The key to hand `GoogleReCaptchaProvider` on this render — `''` until `armed`.\n *\n * ─────────────────────────────────────────────────────────────────────────────\n * WHAT THIS BUYS\n * ─────────────────────────────────────────────────────────────────────────────\n *\n * `GoogleReCaptchaProvider` loads Google's `api.js` the moment it MOUNTS. Mount\n * it with the form and every visitor who merely lands on the page to read the\n * phone number has contacted Google — on a clinic's contact page, a third-party\n * request nobody asked for, on a route whose whole subject is personal data.\n *\n * Arming on first interaction with the form (focus, first keystroke) means\n * Google is contacted only for a visitor who is actually about to submit, which\n * is the only visitor whose token is worth anything. It is a smaller page, a\n * faster one, and a materially smaller consent surface.\n *\n * **This is NOT a cookie-consent implementation.** Deferring the script narrows\n * the exposure; it does not gate it on a lawful basis. Full consent gating for\n * third parties is REB-642 and has its own owner.\n *\n * ─────────────────────────────────────────────────────────────────────────────\n * HOW, WITHOUT A HACK — MEASURED, NOT ASSUMED\n * ─────────────────────────────────────────────────────────────────────────────\n *\n * `react-google-recaptcha-v3` injects the script from an effect shaped\n *\n * useEffect(() => { if (reCaptchaKey) { …inject…; return cleanup } … },\n * [ …, reCaptchaKey, … ])\n *\n * Two facts fall out, and together they are the whole mechanism:\n *\n * 1. An EMPTY key mounts the provider and injects NOTHING.\n * 2. `reCaptchaKey` is a dependency, so swapping the real key in later re-runs\n * the effect and injects the script then.\n *\n * Crucially the provider element never leaves the tree, so its children are\n * never remounted: the visitor does not lose focus, a half-typed word, or the\n * caret position at the instant the script arrives. This is the library's own\n * documented prop doing its documented job — no imperative script injection, no\n * reaching into the DOM, no fork.\n *\n * Both facts are pinned by `tests/recaptcha-deferred-oracle.test.tsx`, which\n * drives the REAL library and counts script tags in the DOM. They are claims\n * about someone else's code, so prose asserting them is not enough — if a future\n * version of the library injects eagerly, that test goes red and this whole\n * optimisation is known to be void.\n *\n * ── THE ONE COST, MEASURED ──\n *\n * On the empty-key render the library calls its own warn helper, which is\n * written inverted, so the warning fires in PRODUCTION and stays silent in\n * development. A visitor who opens devtools before touching a field sees one\n * `\"<GoogleReCaptchaProvider /> recaptcha key not provided\"`. Cosmetic console\n * noise, traded against a third-party request on every view of the page. Worth\n * it, and recorded here so the next reader knows it is expected.\n *\n * ── AND NO FALLBACK KEY, EVER ──\n *\n * `''` for a blank OR malformed key however armed. There is no hardcoded\n * fallback anywhere in this module, because a captcha key that is not the\n * tenant's fails Google's verification while LOOKING configured — converting a\n * visible gap an owner can fix in nexus into a silent one nobody can.\n * {@link resolveContactFormAvailability} hides the form in that case; this is\n * the second lock on the same door.\n */\nexport function deferredRecaptchaKey(siteKey: string, armed: boolean): string {\n if (!armed) return ''\n const key = trimmed(siteKey)\n return key && isSafeRecaptchaSiteKey(key) ? key : ''\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAyEO,IAAM,mBAAmB;AAiCzB,IAAM,6BAA6B;AAQnC,SAAS,uBAAuB,OAAiC;AACtE,SAAO,OAAO,UAAU,YAAY,2BAA2B,KAAK,KAAK;AAC3E;AAiBO,IAAM,iCAAiC,CAAC,WAAW,WAAW,QAAQ;AAoD7E,SAAS,QAAQ,OAA+B;AAC9C,MAAI,OAAO,UAAU,SAAU,QAAO;AACtC,QAAM,OAAO,MAAM,KAAK;AACxB,SAAO,KAAK,SAAS,IAAI,OAAO;AAClC;AAEA,SAAS,kBAAkB,MAAmD;AAI5E,SAAQ,+BAAqD,SAAS,IAAc;AACtF;AAeO,SAAS,+BACd,WACyB;AACzB,MAAI,CAAC,UAAW,QAAO,EAAE,OAAO,eAAe,QAAQ,aAAa;AAEpE,QAAM,OAAO,UAAU;AACvB,MAAI,CAAC,kBAAkB,IAAI,EAAG,QAAO,EAAE,OAAO,eAAe,QAAQ,uBAAuB;AAO5F,QAAM,mBAAmB,QAAQ,UAAU,gBAAgB;AAC3D,MAAI,CAAC,iBAAkB,QAAO,EAAE,OAAO,eAAe,QAAQ,wBAAwB;AACtF,MAAI,CAAC,uBAAuB,gBAAgB,GAAG;AAC7C,WAAO,EAAE,OAAO,eAAe,QAAQ,0BAA0B;AAAA,EACnE;AAEA,SAAO,EAAE,OAAO,aAAa,MAAM,iBAAiB;AACtD;AAGO,SAAS,uBACd,cAC0E;AAC1E,SAAO,aAAa,UAAU;AAChC;AAoEO,SAAS,qBAAqB,SAAiB,OAAwB;AAC5E,MAAI,CAAC,MAAO,QAAO;AACnB,QAAM,MAAM,QAAQ,OAAO;AAC3B,SAAO,OAAO,uBAAuB,GAAG,IAAI,MAAM;AACpD;","names":[]}
@@ -0,0 +1,264 @@
1
+ import { ContactFormMode, Analytics } from '@reblu/site-contracts';
2
+
3
+ /**
4
+ * reCAPTCHA v3 — the SITE's half of the contract (REB-588).
5
+ *
6
+ * ─────────────────────────────────────────────────────────────────────────────
7
+ * WHICH HALF LIVES WHERE, AND WHY
8
+ * ─────────────────────────────────────────────────────────────────────────────
9
+ *
10
+ * reCAPTCHA is two secrets and two steps, and they are deliberately split across
11
+ * a trust boundary:
12
+ *
13
+ * SITE KEY — public by design. It ships to the browser inside the script URL,
14
+ * so the tenant serves it from `/api/site/analytics`. This module
15
+ * handles it.
16
+ * SECRET — never leaves base-api. It is stored per tenant in
17
+ * `site_integration_config.recaptchaSecretKey` and read only by
18
+ * `serverCaptchaVerify` when `POST /api/site/contact-form` runs.
19
+ *
20
+ * **Nothing in this module, in this package, or anywhere in the `@reblu/site-*`
21
+ * SDK ever sees the secret, and nothing here calls Google's `siteverify`.** A
22
+ * SITE holding the secret would be a SITE that could be tricked into approving
23
+ * its own tokens, and a distributable SDK is the worst possible place to put a
24
+ * credential. Verification is base-api's job and already works: the SITE mints a
25
+ * token, posts it as `ContactFormInput.captcha`, and base-api decides.
26
+ *
27
+ * That is also why this module is NOT "reCAPTCHA support" in the round. It is
28
+ * the three decisions every SITE was otherwise making alone and differently:
29
+ * which action to name, whether a form may be shown at all, and when Google's
30
+ * script is allowed to load.
31
+ *
32
+ * ─────────────────────────────────────────────────────────────────────────────
33
+ * WHY IT IS IN THE SDK AT ALL
34
+ * ─────────────────────────────────────────────────────────────────────────────
35
+ *
36
+ * Because it was already being written four times. `apps/base-api`,
37
+ * `apps/demo-site` and the external `reblu-site-dermamedicin` each grew their
38
+ * own copy of this flow, and they had already drifted — different action
39
+ * strings, different mount timing, a retry loop in one of them. A score that
40
+ * reCAPTCHA reports per action is not comparable across SITEs that name the
41
+ * action differently, and a defect fixed in one copy stays live in the other
42
+ * two. The SDK is where a decision gets made once.
43
+ *
44
+ * This module has ZERO runtime dependencies and imports nothing — not React, not
45
+ * `next`, not `react-google-recaptcha-v3`, not `@reblu/site-ui`. It is pure
46
+ * functions over plain data, callable from a Server Component deciding whether
47
+ * to render a form and from the Client Component that renders it. The subpath is
48
+ * kept dependency-free by `tests/recaptcha-subpath-isolation.test.ts`.
49
+ */
50
+
51
+ /**
52
+ * The action name every Reblu SITE mints its contact-form token under.
53
+ *
54
+ * reCAPTCHA v3 scores PER ACTION, and a score is only interpretable against a
55
+ * stable one: Google's own admin console charts each action separately, and a
56
+ * threshold tuned on one action says nothing about another. Two SITEs calling
57
+ * `executeRecaptcha('submit_form')` and `executeRecaptcha('contact_form')` are
58
+ * building two unrelated score histories for the same feature — which is exactly
59
+ * the state the repo was in before this constant existed.
60
+ *
61
+ * `contact_form` over `submit_form` because it names the form, not the verb;
62
+ * a second protected form would need its own action, and `submit_form` would
63
+ * have nothing left to call it.
64
+ *
65
+ * ── WHAT THIS CONSTANT DOES **NOT** BUY, STATED PLAINLY ──
66
+ *
67
+ * It buys comparable scores. It does NOT bind a token to this form, because
68
+ * `serverCaptchaVerify` in base-api checks only `success` and `score`, and never
69
+ * reads the `action` field Google returns. A token minted for any other action
70
+ * on the same site verifies here exactly as well. Making the action load-bearing
71
+ * requires base-api to assert `action === RECAPTCHA_ACTION` on verify; until it
72
+ * does, do not read this constant as a security control.
73
+ */
74
+ declare const RECAPTCHA_ACTION = "contact_form";
75
+ /**
76
+ * The shape a site key must have before this SDK will hand it to Google.
77
+ *
78
+ * ── WHAT THIS IS, AND EMPHATICALLY WHAT IT IS NOT ──
79
+ *
80
+ * It is a SINK-SAFETY guard, not a guess at Google's key format. The key is
81
+ * interpolated into the loader's script URL as a query parameter, so what
82
+ * matters here is that it cannot carry a space, a quote, a newline, an `&`, or a
83
+ * `#` — characters that make a URL mean something other than what was intended.
84
+ * The base64url alphabet Google actually issues from (`A-Z a-z 0-9 - _`) is a
85
+ * strict subset of "safe", so a real key passes by construction.
86
+ *
87
+ * The length bounds are deliberately far wider than any key Google issues
88
+ * (theirs are 40 characters). A bound tight enough to be a real format check
89
+ * would be a bound tight enough to take a WORKING tenant's contact form offline
90
+ * the day Google changes its key length — which is strictly worse than the
91
+ * malformed key it was meant to catch. If you are tempted to tighten this,
92
+ * the thing to tighten is the nexus write path, where an owner can still see
93
+ * and fix the error, not this read path where they cannot.
94
+ *
95
+ * ── WHY THE SDK RE-VALIDATES AT ALL ──
96
+ *
97
+ * Same reason `@reblu/site-next/analytics` re-checks `gaId` and `clarityId`
98
+ * (REB-617): base-api is not the only thing a SITE ever talks to, and the layer
99
+ * that owns the value is not the layer that suffers from it being wrong. As of
100
+ * REB-588 this column has NO validation anywhere — not in the nexus form, not on
101
+ * the way out of `/api/site/analytics`, not in the response contract, which is
102
+ * the one asymmetry `gaId`/`clarityId` do not share. Until that is fixed
103
+ * upstream, this is the only guard between a mistyped key and a contact form
104
+ * that renders, looks live, and silently eats every enquiry.
105
+ */
106
+ declare const RECAPTCHA_SITE_KEY_PATTERN: RegExp;
107
+ /**
108
+ * Whether `value` is a site key safe to put in the loader's script URL.
109
+ *
110
+ * Takes `unknown` on purpose: the value arrives over HTTP, so the contract's
111
+ * `string | null` is a promise about the shape, not a guarantee about the bytes.
112
+ */
113
+ declare function isSafeRecaptchaSiteKey(value: unknown): value is string;
114
+ /**
115
+ * The modes in which base-api will actually accept a submission.
116
+ *
117
+ * An ALLOW-LIST, never `mode !== 'DISABLED'`. The three differ only in what
118
+ * base-api DOES with the payload — a row, a webhook, an email — and the SITE
119
+ * must not care and must never learn the destination: a webhook URL or a Resend
120
+ * key is a tenant secret with no business in a public site.
121
+ *
122
+ * The deny-list version of this is a real incident, not a hypothetical: a page
123
+ * that hid the form on `DISABLED` and rendered it otherwise rendered it for
124
+ * `NOT_CONFIGURED` too — which is what EVERY freshly provisioned tenant carries
125
+ * until someone finishes the wizard (REB-586). A visitor typed out a medical
126
+ * enquiry, pressed send, and got `503 Contact form is disabled`. They learned
127
+ * the clinic could not be reached only after writing to it.
128
+ */
129
+ declare const CONTACT_FORM_OPERATIONAL_MODES: readonly ["DB_ONLY", "WEBHOOK", "RESEND"];
130
+ /** A mode in which the form may be shown. */
131
+ type ContactFormOperationalMode = (typeof CONTACT_FORM_OPERATIONAL_MODES)[number];
132
+ /**
133
+ * The modes deliberately declined. A TYPE, never a value: the runtime must never
134
+ * read it, because an allow-list must not be expressible as "everything except
135
+ * this list", and this subpath's defining property is that it ships nothing it
136
+ * does not need. It exists only to feed {@link ContactFormModeExhaustiveCheck}.
137
+ */
138
+ type DeclinedMode = 'NOT_CONFIGURED' | 'DISABLED';
139
+ /**
140
+ * Compile-time exhaustiveness guard over `ContactFormMode`.
141
+ *
142
+ * `Missing` is constrained to `never` and DEFAULTS to the contract modes that
143
+ * are neither accepted nor explicitly declined, so a default violating its own
144
+ * constraint breaks the build the moment `@reblu/site-contracts` gains a mode.
145
+ *
146
+ * At runtime a new mode already fails closed, which is correct but SILENT — and
147
+ * silence is how a tenant switches their form on in nexus and watches nothing
148
+ * appear. This forces a human to make the call in a diff instead.
149
+ *
150
+ * Exported so it is never reported as an unused symbol.
151
+ */
152
+ type ContactFormModeExhaustiveCheck<Missing extends never = Exclude<ContactFormMode, ContactFormOperationalMode | DeclinedMode>> = Missing;
153
+ /** Why the form is not being shown. Drives diagnostics, never visitor-facing copy. */
154
+ type ContactFormUnavailableReason =
155
+ /** The analytics read failed or was never made — no answer at all. */
156
+ 'unreadable'
157
+ /** The tenant's mode is not one base-api accepts submissions in. */
158
+ | 'mode-not-operational'
159
+ /** Accepting mode, but no site key: the browser cannot mint a token. */
160
+ | 'missing-recaptcha-key'
161
+ /** A site key is present but cannot be a key — see {@link RECAPTCHA_SITE_KEY_PATTERN}. */
162
+ | 'malformed-recaptcha-key';
163
+ type ContactFormAvailability =
164
+ /**
165
+ * Confirmed: base-api accepts submissions AND a usable site key exists. The
166
+ * key travels inside the `available` case on purpose — a component that has
167
+ * proven the form may render should not have to re-read (and re-null-check)
168
+ * the payload to find out what to render it WITH.
169
+ */
170
+ {
171
+ state: 'available';
172
+ mode: ContactFormOperationalMode;
173
+ recaptchaSiteKey: string;
174
+ } | {
175
+ state: 'unavailable';
176
+ reason: ContactFormUnavailableReason;
177
+ };
178
+ /**
179
+ * Resolve whether a contact form may be rendered for this tenant.
180
+ *
181
+ * We offer what we can CONFIRM, never what we have merely failed to disprove: an
182
+ * unknown mode, a mode from a runtime newer than this build, and a config read
183
+ * that threw all resolve to the same answer — no form. The page still shows the
184
+ * phone, the email and the address, so a hidden form costs a visitor one extra
185
+ * glance; a form that eats their message costs them the message.
186
+ *
187
+ * Order matters. The mode is checked BEFORE the key, because "the tenant has not
188
+ * switched the form on" is the actionable half of a double failure — a site key
189
+ * for a form nobody enabled is not the problem to fix first.
190
+ */
191
+ declare function resolveContactFormAvailability(analytics: Analytics | null | undefined): ContactFormAvailability;
192
+ /** Narrowing helper — the single question a renderer should ask. */
193
+ declare function isContactFormAvailable(availability: ContactFormAvailability): availability is Extract<ContactFormAvailability, {
194
+ state: 'available';
195
+ }>;
196
+ /**
197
+ * The key to hand `GoogleReCaptchaProvider` on this render — `''` until `armed`.
198
+ *
199
+ * ─────────────────────────────────────────────────────────────────────────────
200
+ * WHAT THIS BUYS
201
+ * ─────────────────────────────────────────────────────────────────────────────
202
+ *
203
+ * `GoogleReCaptchaProvider` loads Google's `api.js` the moment it MOUNTS. Mount
204
+ * it with the form and every visitor who merely lands on the page to read the
205
+ * phone number has contacted Google — on a clinic's contact page, a third-party
206
+ * request nobody asked for, on a route whose whole subject is personal data.
207
+ *
208
+ * Arming on first interaction with the form (focus, first keystroke) means
209
+ * Google is contacted only for a visitor who is actually about to submit, which
210
+ * is the only visitor whose token is worth anything. It is a smaller page, a
211
+ * faster one, and a materially smaller consent surface.
212
+ *
213
+ * **This is NOT a cookie-consent implementation.** Deferring the script narrows
214
+ * the exposure; it does not gate it on a lawful basis. Full consent gating for
215
+ * third parties is REB-642 and has its own owner.
216
+ *
217
+ * ─────────────────────────────────────────────────────────────────────────────
218
+ * HOW, WITHOUT A HACK — MEASURED, NOT ASSUMED
219
+ * ─────────────────────────────────────────────────────────────────────────────
220
+ *
221
+ * `react-google-recaptcha-v3` injects the script from an effect shaped
222
+ *
223
+ * useEffect(() => { if (reCaptchaKey) { …inject…; return cleanup } … },
224
+ * [ …, reCaptchaKey, … ])
225
+ *
226
+ * Two facts fall out, and together they are the whole mechanism:
227
+ *
228
+ * 1. An EMPTY key mounts the provider and injects NOTHING.
229
+ * 2. `reCaptchaKey` is a dependency, so swapping the real key in later re-runs
230
+ * the effect and injects the script then.
231
+ *
232
+ * Crucially the provider element never leaves the tree, so its children are
233
+ * never remounted: the visitor does not lose focus, a half-typed word, or the
234
+ * caret position at the instant the script arrives. This is the library's own
235
+ * documented prop doing its documented job — no imperative script injection, no
236
+ * reaching into the DOM, no fork.
237
+ *
238
+ * Both facts are pinned by `tests/recaptcha-deferred-oracle.test.tsx`, which
239
+ * drives the REAL library and counts script tags in the DOM. They are claims
240
+ * about someone else's code, so prose asserting them is not enough — if a future
241
+ * version of the library injects eagerly, that test goes red and this whole
242
+ * optimisation is known to be void.
243
+ *
244
+ * ── THE ONE COST, MEASURED ──
245
+ *
246
+ * On the empty-key render the library calls its own warn helper, which is
247
+ * written inverted, so the warning fires in PRODUCTION and stays silent in
248
+ * development. A visitor who opens devtools before touching a field sees one
249
+ * `"<GoogleReCaptchaProvider /> recaptcha key not provided"`. Cosmetic console
250
+ * noise, traded against a third-party request on every view of the page. Worth
251
+ * it, and recorded here so the next reader knows it is expected.
252
+ *
253
+ * ── AND NO FALLBACK KEY, EVER ──
254
+ *
255
+ * `''` for a blank OR malformed key however armed. There is no hardcoded
256
+ * fallback anywhere in this module, because a captcha key that is not the
257
+ * tenant's fails Google's verification while LOOKING configured — converting a
258
+ * visible gap an owner can fix in nexus into a silent one nobody can.
259
+ * {@link resolveContactFormAvailability} hides the form in that case; this is
260
+ * the second lock on the same door.
261
+ */
262
+ declare function deferredRecaptchaKey(siteKey: string, armed: boolean): string;
263
+
264
+ export { CONTACT_FORM_OPERATIONAL_MODES, type ContactFormAvailability, type ContactFormModeExhaustiveCheck, type ContactFormOperationalMode, type ContactFormUnavailableReason, RECAPTCHA_ACTION, RECAPTCHA_SITE_KEY_PATTERN, deferredRecaptchaKey, isContactFormAvailable, isSafeRecaptchaSiteKey, resolveContactFormAvailability };
@@ -0,0 +1,264 @@
1
+ import { ContactFormMode, Analytics } from '@reblu/site-contracts';
2
+
3
+ /**
4
+ * reCAPTCHA v3 — the SITE's half of the contract (REB-588).
5
+ *
6
+ * ─────────────────────────────────────────────────────────────────────────────
7
+ * WHICH HALF LIVES WHERE, AND WHY
8
+ * ─────────────────────────────────────────────────────────────────────────────
9
+ *
10
+ * reCAPTCHA is two secrets and two steps, and they are deliberately split across
11
+ * a trust boundary:
12
+ *
13
+ * SITE KEY — public by design. It ships to the browser inside the script URL,
14
+ * so the tenant serves it from `/api/site/analytics`. This module
15
+ * handles it.
16
+ * SECRET — never leaves base-api. It is stored per tenant in
17
+ * `site_integration_config.recaptchaSecretKey` and read only by
18
+ * `serverCaptchaVerify` when `POST /api/site/contact-form` runs.
19
+ *
20
+ * **Nothing in this module, in this package, or anywhere in the `@reblu/site-*`
21
+ * SDK ever sees the secret, and nothing here calls Google's `siteverify`.** A
22
+ * SITE holding the secret would be a SITE that could be tricked into approving
23
+ * its own tokens, and a distributable SDK is the worst possible place to put a
24
+ * credential. Verification is base-api's job and already works: the SITE mints a
25
+ * token, posts it as `ContactFormInput.captcha`, and base-api decides.
26
+ *
27
+ * That is also why this module is NOT "reCAPTCHA support" in the round. It is
28
+ * the three decisions every SITE was otherwise making alone and differently:
29
+ * which action to name, whether a form may be shown at all, and when Google's
30
+ * script is allowed to load.
31
+ *
32
+ * ─────────────────────────────────────────────────────────────────────────────
33
+ * WHY IT IS IN THE SDK AT ALL
34
+ * ─────────────────────────────────────────────────────────────────────────────
35
+ *
36
+ * Because it was already being written four times. `apps/base-api`,
37
+ * `apps/demo-site` and the external `reblu-site-dermamedicin` each grew their
38
+ * own copy of this flow, and they had already drifted — different action
39
+ * strings, different mount timing, a retry loop in one of them. A score that
40
+ * reCAPTCHA reports per action is not comparable across SITEs that name the
41
+ * action differently, and a defect fixed in one copy stays live in the other
42
+ * two. The SDK is where a decision gets made once.
43
+ *
44
+ * This module has ZERO runtime dependencies and imports nothing — not React, not
45
+ * `next`, not `react-google-recaptcha-v3`, not `@reblu/site-ui`. It is pure
46
+ * functions over plain data, callable from a Server Component deciding whether
47
+ * to render a form and from the Client Component that renders it. The subpath is
48
+ * kept dependency-free by `tests/recaptcha-subpath-isolation.test.ts`.
49
+ */
50
+
51
+ /**
52
+ * The action name every Reblu SITE mints its contact-form token under.
53
+ *
54
+ * reCAPTCHA v3 scores PER ACTION, and a score is only interpretable against a
55
+ * stable one: Google's own admin console charts each action separately, and a
56
+ * threshold tuned on one action says nothing about another. Two SITEs calling
57
+ * `executeRecaptcha('submit_form')` and `executeRecaptcha('contact_form')` are
58
+ * building two unrelated score histories for the same feature — which is exactly
59
+ * the state the repo was in before this constant existed.
60
+ *
61
+ * `contact_form` over `submit_form` because it names the form, not the verb;
62
+ * a second protected form would need its own action, and `submit_form` would
63
+ * have nothing left to call it.
64
+ *
65
+ * ── WHAT THIS CONSTANT DOES **NOT** BUY, STATED PLAINLY ──
66
+ *
67
+ * It buys comparable scores. It does NOT bind a token to this form, because
68
+ * `serverCaptchaVerify` in base-api checks only `success` and `score`, and never
69
+ * reads the `action` field Google returns. A token minted for any other action
70
+ * on the same site verifies here exactly as well. Making the action load-bearing
71
+ * requires base-api to assert `action === RECAPTCHA_ACTION` on verify; until it
72
+ * does, do not read this constant as a security control.
73
+ */
74
+ declare const RECAPTCHA_ACTION = "contact_form";
75
+ /**
76
+ * The shape a site key must have before this SDK will hand it to Google.
77
+ *
78
+ * ── WHAT THIS IS, AND EMPHATICALLY WHAT IT IS NOT ──
79
+ *
80
+ * It is a SINK-SAFETY guard, not a guess at Google's key format. The key is
81
+ * interpolated into the loader's script URL as a query parameter, so what
82
+ * matters here is that it cannot carry a space, a quote, a newline, an `&`, or a
83
+ * `#` — characters that make a URL mean something other than what was intended.
84
+ * The base64url alphabet Google actually issues from (`A-Z a-z 0-9 - _`) is a
85
+ * strict subset of "safe", so a real key passes by construction.
86
+ *
87
+ * The length bounds are deliberately far wider than any key Google issues
88
+ * (theirs are 40 characters). A bound tight enough to be a real format check
89
+ * would be a bound tight enough to take a WORKING tenant's contact form offline
90
+ * the day Google changes its key length — which is strictly worse than the
91
+ * malformed key it was meant to catch. If you are tempted to tighten this,
92
+ * the thing to tighten is the nexus write path, where an owner can still see
93
+ * and fix the error, not this read path where they cannot.
94
+ *
95
+ * ── WHY THE SDK RE-VALIDATES AT ALL ──
96
+ *
97
+ * Same reason `@reblu/site-next/analytics` re-checks `gaId` and `clarityId`
98
+ * (REB-617): base-api is not the only thing a SITE ever talks to, and the layer
99
+ * that owns the value is not the layer that suffers from it being wrong. As of
100
+ * REB-588 this column has NO validation anywhere — not in the nexus form, not on
101
+ * the way out of `/api/site/analytics`, not in the response contract, which is
102
+ * the one asymmetry `gaId`/`clarityId` do not share. Until that is fixed
103
+ * upstream, this is the only guard between a mistyped key and a contact form
104
+ * that renders, looks live, and silently eats every enquiry.
105
+ */
106
+ declare const RECAPTCHA_SITE_KEY_PATTERN: RegExp;
107
+ /**
108
+ * Whether `value` is a site key safe to put in the loader's script URL.
109
+ *
110
+ * Takes `unknown` on purpose: the value arrives over HTTP, so the contract's
111
+ * `string | null` is a promise about the shape, not a guarantee about the bytes.
112
+ */
113
+ declare function isSafeRecaptchaSiteKey(value: unknown): value is string;
114
+ /**
115
+ * The modes in which base-api will actually accept a submission.
116
+ *
117
+ * An ALLOW-LIST, never `mode !== 'DISABLED'`. The three differ only in what
118
+ * base-api DOES with the payload — a row, a webhook, an email — and the SITE
119
+ * must not care and must never learn the destination: a webhook URL or a Resend
120
+ * key is a tenant secret with no business in a public site.
121
+ *
122
+ * The deny-list version of this is a real incident, not a hypothetical: a page
123
+ * that hid the form on `DISABLED` and rendered it otherwise rendered it for
124
+ * `NOT_CONFIGURED` too — which is what EVERY freshly provisioned tenant carries
125
+ * until someone finishes the wizard (REB-586). A visitor typed out a medical
126
+ * enquiry, pressed send, and got `503 Contact form is disabled`. They learned
127
+ * the clinic could not be reached only after writing to it.
128
+ */
129
+ declare const CONTACT_FORM_OPERATIONAL_MODES: readonly ["DB_ONLY", "WEBHOOK", "RESEND"];
130
+ /** A mode in which the form may be shown. */
131
+ type ContactFormOperationalMode = (typeof CONTACT_FORM_OPERATIONAL_MODES)[number];
132
+ /**
133
+ * The modes deliberately declined. A TYPE, never a value: the runtime must never
134
+ * read it, because an allow-list must not be expressible as "everything except
135
+ * this list", and this subpath's defining property is that it ships nothing it
136
+ * does not need. It exists only to feed {@link ContactFormModeExhaustiveCheck}.
137
+ */
138
+ type DeclinedMode = 'NOT_CONFIGURED' | 'DISABLED';
139
+ /**
140
+ * Compile-time exhaustiveness guard over `ContactFormMode`.
141
+ *
142
+ * `Missing` is constrained to `never` and DEFAULTS to the contract modes that
143
+ * are neither accepted nor explicitly declined, so a default violating its own
144
+ * constraint breaks the build the moment `@reblu/site-contracts` gains a mode.
145
+ *
146
+ * At runtime a new mode already fails closed, which is correct but SILENT — and
147
+ * silence is how a tenant switches their form on in nexus and watches nothing
148
+ * appear. This forces a human to make the call in a diff instead.
149
+ *
150
+ * Exported so it is never reported as an unused symbol.
151
+ */
152
+ type ContactFormModeExhaustiveCheck<Missing extends never = Exclude<ContactFormMode, ContactFormOperationalMode | DeclinedMode>> = Missing;
153
+ /** Why the form is not being shown. Drives diagnostics, never visitor-facing copy. */
154
+ type ContactFormUnavailableReason =
155
+ /** The analytics read failed or was never made — no answer at all. */
156
+ 'unreadable'
157
+ /** The tenant's mode is not one base-api accepts submissions in. */
158
+ | 'mode-not-operational'
159
+ /** Accepting mode, but no site key: the browser cannot mint a token. */
160
+ | 'missing-recaptcha-key'
161
+ /** A site key is present but cannot be a key — see {@link RECAPTCHA_SITE_KEY_PATTERN}. */
162
+ | 'malformed-recaptcha-key';
163
+ type ContactFormAvailability =
164
+ /**
165
+ * Confirmed: base-api accepts submissions AND a usable site key exists. The
166
+ * key travels inside the `available` case on purpose — a component that has
167
+ * proven the form may render should not have to re-read (and re-null-check)
168
+ * the payload to find out what to render it WITH.
169
+ */
170
+ {
171
+ state: 'available';
172
+ mode: ContactFormOperationalMode;
173
+ recaptchaSiteKey: string;
174
+ } | {
175
+ state: 'unavailable';
176
+ reason: ContactFormUnavailableReason;
177
+ };
178
+ /**
179
+ * Resolve whether a contact form may be rendered for this tenant.
180
+ *
181
+ * We offer what we can CONFIRM, never what we have merely failed to disprove: an
182
+ * unknown mode, a mode from a runtime newer than this build, and a config read
183
+ * that threw all resolve to the same answer — no form. The page still shows the
184
+ * phone, the email and the address, so a hidden form costs a visitor one extra
185
+ * glance; a form that eats their message costs them the message.
186
+ *
187
+ * Order matters. The mode is checked BEFORE the key, because "the tenant has not
188
+ * switched the form on" is the actionable half of a double failure — a site key
189
+ * for a form nobody enabled is not the problem to fix first.
190
+ */
191
+ declare function resolveContactFormAvailability(analytics: Analytics | null | undefined): ContactFormAvailability;
192
+ /** Narrowing helper — the single question a renderer should ask. */
193
+ declare function isContactFormAvailable(availability: ContactFormAvailability): availability is Extract<ContactFormAvailability, {
194
+ state: 'available';
195
+ }>;
196
+ /**
197
+ * The key to hand `GoogleReCaptchaProvider` on this render — `''` until `armed`.
198
+ *
199
+ * ─────────────────────────────────────────────────────────────────────────────
200
+ * WHAT THIS BUYS
201
+ * ─────────────────────────────────────────────────────────────────────────────
202
+ *
203
+ * `GoogleReCaptchaProvider` loads Google's `api.js` the moment it MOUNTS. Mount
204
+ * it with the form and every visitor who merely lands on the page to read the
205
+ * phone number has contacted Google — on a clinic's contact page, a third-party
206
+ * request nobody asked for, on a route whose whole subject is personal data.
207
+ *
208
+ * Arming on first interaction with the form (focus, first keystroke) means
209
+ * Google is contacted only for a visitor who is actually about to submit, which
210
+ * is the only visitor whose token is worth anything. It is a smaller page, a
211
+ * faster one, and a materially smaller consent surface.
212
+ *
213
+ * **This is NOT a cookie-consent implementation.** Deferring the script narrows
214
+ * the exposure; it does not gate it on a lawful basis. Full consent gating for
215
+ * third parties is REB-642 and has its own owner.
216
+ *
217
+ * ─────────────────────────────────────────────────────────────────────────────
218
+ * HOW, WITHOUT A HACK — MEASURED, NOT ASSUMED
219
+ * ─────────────────────────────────────────────────────────────────────────────
220
+ *
221
+ * `react-google-recaptcha-v3` injects the script from an effect shaped
222
+ *
223
+ * useEffect(() => { if (reCaptchaKey) { …inject…; return cleanup } … },
224
+ * [ …, reCaptchaKey, … ])
225
+ *
226
+ * Two facts fall out, and together they are the whole mechanism:
227
+ *
228
+ * 1. An EMPTY key mounts the provider and injects NOTHING.
229
+ * 2. `reCaptchaKey` is a dependency, so swapping the real key in later re-runs
230
+ * the effect and injects the script then.
231
+ *
232
+ * Crucially the provider element never leaves the tree, so its children are
233
+ * never remounted: the visitor does not lose focus, a half-typed word, or the
234
+ * caret position at the instant the script arrives. This is the library's own
235
+ * documented prop doing its documented job — no imperative script injection, no
236
+ * reaching into the DOM, no fork.
237
+ *
238
+ * Both facts are pinned by `tests/recaptcha-deferred-oracle.test.tsx`, which
239
+ * drives the REAL library and counts script tags in the DOM. They are claims
240
+ * about someone else's code, so prose asserting them is not enough — if a future
241
+ * version of the library injects eagerly, that test goes red and this whole
242
+ * optimisation is known to be void.
243
+ *
244
+ * ── THE ONE COST, MEASURED ──
245
+ *
246
+ * On the empty-key render the library calls its own warn helper, which is
247
+ * written inverted, so the warning fires in PRODUCTION and stays silent in
248
+ * development. A visitor who opens devtools before touching a field sees one
249
+ * `"<GoogleReCaptchaProvider /> recaptcha key not provided"`. Cosmetic console
250
+ * noise, traded against a third-party request on every view of the page. Worth
251
+ * it, and recorded here so the next reader knows it is expected.
252
+ *
253
+ * ── AND NO FALLBACK KEY, EVER ──
254
+ *
255
+ * `''` for a blank OR malformed key however armed. There is no hardcoded
256
+ * fallback anywhere in this module, because a captcha key that is not the
257
+ * tenant's fails Google's verification while LOOKING configured — converting a
258
+ * visible gap an owner can fix in nexus into a silent one nobody can.
259
+ * {@link resolveContactFormAvailability} hides the form in that case; this is
260
+ * the second lock on the same door.
261
+ */
262
+ declare function deferredRecaptchaKey(siteKey: string, armed: boolean): string;
263
+
264
+ export { CONTACT_FORM_OPERATIONAL_MODES, type ContactFormAvailability, type ContactFormModeExhaustiveCheck, type ContactFormOperationalMode, type ContactFormUnavailableReason, RECAPTCHA_ACTION, RECAPTCHA_SITE_KEY_PATTERN, deferredRecaptchaKey, isContactFormAvailable, isSafeRecaptchaSiteKey, resolveContactFormAvailability };
@@ -0,0 +1,44 @@
1
+ // src/recaptcha.ts
2
+ var RECAPTCHA_ACTION = "contact_form";
3
+ var RECAPTCHA_SITE_KEY_PATTERN = /^[A-Za-z0-9_-]{16,128}$/;
4
+ function isSafeRecaptchaSiteKey(value) {
5
+ return typeof value === "string" && RECAPTCHA_SITE_KEY_PATTERN.test(value);
6
+ }
7
+ var CONTACT_FORM_OPERATIONAL_MODES = ["DB_ONLY", "WEBHOOK", "RESEND"];
8
+ function trimmed(value) {
9
+ if (typeof value !== "string") return null;
10
+ const text = value.trim();
11
+ return text.length > 0 ? text : null;
12
+ }
13
+ function isOperationalMode(mode) {
14
+ return CONTACT_FORM_OPERATIONAL_MODES.includes(mode);
15
+ }
16
+ function resolveContactFormAvailability(analytics) {
17
+ if (!analytics) return { state: "unavailable", reason: "unreadable" };
18
+ const mode = analytics.contactFormMode;
19
+ if (!isOperationalMode(mode)) return { state: "unavailable", reason: "mode-not-operational" };
20
+ const recaptchaSiteKey = trimmed(analytics.recaptchaSiteKey);
21
+ if (!recaptchaSiteKey) return { state: "unavailable", reason: "missing-recaptcha-key" };
22
+ if (!isSafeRecaptchaSiteKey(recaptchaSiteKey)) {
23
+ return { state: "unavailable", reason: "malformed-recaptcha-key" };
24
+ }
25
+ return { state: "available", mode, recaptchaSiteKey };
26
+ }
27
+ function isContactFormAvailable(availability) {
28
+ return availability.state === "available";
29
+ }
30
+ function deferredRecaptchaKey(siteKey, armed) {
31
+ if (!armed) return "";
32
+ const key = trimmed(siteKey);
33
+ return key && isSafeRecaptchaSiteKey(key) ? key : "";
34
+ }
35
+ export {
36
+ CONTACT_FORM_OPERATIONAL_MODES,
37
+ RECAPTCHA_ACTION,
38
+ RECAPTCHA_SITE_KEY_PATTERN,
39
+ deferredRecaptchaKey,
40
+ isContactFormAvailable,
41
+ isSafeRecaptchaSiteKey,
42
+ resolveContactFormAvailability
43
+ };
44
+ //# sourceMappingURL=recaptcha.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/recaptcha.ts"],"sourcesContent":["/**\n * reCAPTCHA v3 — the SITE's half of the contract (REB-588).\n *\n * ─────────────────────────────────────────────────────────────────────────────\n * WHICH HALF LIVES WHERE, AND WHY\n * ─────────────────────────────────────────────────────────────────────────────\n *\n * reCAPTCHA is two secrets and two steps, and they are deliberately split across\n * a trust boundary:\n *\n * SITE KEY — public by design. It ships to the browser inside the script URL,\n * so the tenant serves it from `/api/site/analytics`. This module\n * handles it.\n * SECRET — never leaves base-api. It is stored per tenant in\n * `site_integration_config.recaptchaSecretKey` and read only by\n * `serverCaptchaVerify` when `POST /api/site/contact-form` runs.\n *\n * **Nothing in this module, in this package, or anywhere in the `@reblu/site-*`\n * SDK ever sees the secret, and nothing here calls Google's `siteverify`.** A\n * SITE holding the secret would be a SITE that could be tricked into approving\n * its own tokens, and a distributable SDK is the worst possible place to put a\n * credential. Verification is base-api's job and already works: the SITE mints a\n * token, posts it as `ContactFormInput.captcha`, and base-api decides.\n *\n * That is also why this module is NOT \"reCAPTCHA support\" in the round. It is\n * the three decisions every SITE was otherwise making alone and differently:\n * which action to name, whether a form may be shown at all, and when Google's\n * script is allowed to load.\n *\n * ─────────────────────────────────────────────────────────────────────────────\n * WHY IT IS IN THE SDK AT ALL\n * ─────────────────────────────────────────────────────────────────────────────\n *\n * Because it was already being written four times. `apps/base-api`,\n * `apps/demo-site` and the external `reblu-site-dermamedicin` each grew their\n * own copy of this flow, and they had already drifted — different action\n * strings, different mount timing, a retry loop in one of them. A score that\n * reCAPTCHA reports per action is not comparable across SITEs that name the\n * action differently, and a defect fixed in one copy stays live in the other\n * two. The SDK is where a decision gets made once.\n *\n * This module has ZERO runtime dependencies and imports nothing — not React, not\n * `next`, not `react-google-recaptcha-v3`, not `@reblu/site-ui`. It is pure\n * functions over plain data, callable from a Server Component deciding whether\n * to render a form and from the Client Component that renders it. The subpath is\n * kept dependency-free by `tests/recaptcha-subpath-isolation.test.ts`.\n */\n\nimport type { Analytics, ContactFormMode } from '@reblu/site-contracts'\n\n/**\n * The action name every Reblu SITE mints its contact-form token under.\n *\n * reCAPTCHA v3 scores PER ACTION, and a score is only interpretable against a\n * stable one: Google's own admin console charts each action separately, and a\n * threshold tuned on one action says nothing about another. Two SITEs calling\n * `executeRecaptcha('submit_form')` and `executeRecaptcha('contact_form')` are\n * building two unrelated score histories for the same feature — which is exactly\n * the state the repo was in before this constant existed.\n *\n * `contact_form` over `submit_form` because it names the form, not the verb;\n * a second protected form would need its own action, and `submit_form` would\n * have nothing left to call it.\n *\n * ── WHAT THIS CONSTANT DOES **NOT** BUY, STATED PLAINLY ──\n *\n * It buys comparable scores. It does NOT bind a token to this form, because\n * `serverCaptchaVerify` in base-api checks only `success` and `score`, and never\n * reads the `action` field Google returns. A token minted for any other action\n * on the same site verifies here exactly as well. Making the action load-bearing\n * requires base-api to assert `action === RECAPTCHA_ACTION` on verify; until it\n * does, do not read this constant as a security control.\n */\nexport const RECAPTCHA_ACTION = 'contact_form'\n\n/**\n * The shape a site key must have before this SDK will hand it to Google.\n *\n * ── WHAT THIS IS, AND EMPHATICALLY WHAT IT IS NOT ──\n *\n * It is a SINK-SAFETY guard, not a guess at Google's key format. The key is\n * interpolated into the loader's script URL as a query parameter, so what\n * matters here is that it cannot carry a space, a quote, a newline, an `&`, or a\n * `#` — characters that make a URL mean something other than what was intended.\n * The base64url alphabet Google actually issues from (`A-Z a-z 0-9 - _`) is a\n * strict subset of \"safe\", so a real key passes by construction.\n *\n * The length bounds are deliberately far wider than any key Google issues\n * (theirs are 40 characters). A bound tight enough to be a real format check\n * would be a bound tight enough to take a WORKING tenant's contact form offline\n * the day Google changes its key length — which is strictly worse than the\n * malformed key it was meant to catch. If you are tempted to tighten this,\n * the thing to tighten is the nexus write path, where an owner can still see\n * and fix the error, not this read path where they cannot.\n *\n * ── WHY THE SDK RE-VALIDATES AT ALL ──\n *\n * Same reason `@reblu/site-next/analytics` re-checks `gaId` and `clarityId`\n * (REB-617): base-api is not the only thing a SITE ever talks to, and the layer\n * that owns the value is not the layer that suffers from it being wrong. As of\n * REB-588 this column has NO validation anywhere — not in the nexus form, not on\n * the way out of `/api/site/analytics`, not in the response contract, which is\n * the one asymmetry `gaId`/`clarityId` do not share. Until that is fixed\n * upstream, this is the only guard between a mistyped key and a contact form\n * that renders, looks live, and silently eats every enquiry.\n */\nexport const RECAPTCHA_SITE_KEY_PATTERN = /^[A-Za-z0-9_-]{16,128}$/\n\n/**\n * Whether `value` is a site key safe to put in the loader's script URL.\n *\n * Takes `unknown` on purpose: the value arrives over HTTP, so the contract's\n * `string | null` is a promise about the shape, not a guarantee about the bytes.\n */\nexport function isSafeRecaptchaSiteKey(value: unknown): value is string {\n return typeof value === 'string' && RECAPTCHA_SITE_KEY_PATTERN.test(value)\n}\n\n/**\n * The modes in which base-api will actually accept a submission.\n *\n * An ALLOW-LIST, never `mode !== 'DISABLED'`. The three differ only in what\n * base-api DOES with the payload — a row, a webhook, an email — and the SITE\n * must not care and must never learn the destination: a webhook URL or a Resend\n * key is a tenant secret with no business in a public site.\n *\n * The deny-list version of this is a real incident, not a hypothetical: a page\n * that hid the form on `DISABLED` and rendered it otherwise rendered it for\n * `NOT_CONFIGURED` too — which is what EVERY freshly provisioned tenant carries\n * until someone finishes the wizard (REB-586). A visitor typed out a medical\n * enquiry, pressed send, and got `503 Contact form is disabled`. They learned\n * the clinic could not be reached only after writing to it.\n */\nexport const CONTACT_FORM_OPERATIONAL_MODES = ['DB_ONLY', 'WEBHOOK', 'RESEND'] as const\n\n/** A mode in which the form may be shown. */\nexport type ContactFormOperationalMode = (typeof CONTACT_FORM_OPERATIONAL_MODES)[number]\n\n/**\n * The modes deliberately declined. A TYPE, never a value: the runtime must never\n * read it, because an allow-list must not be expressible as \"everything except\n * this list\", and this subpath's defining property is that it ships nothing it\n * does not need. It exists only to feed {@link ContactFormModeExhaustiveCheck}.\n */\ntype DeclinedMode = 'NOT_CONFIGURED' | 'DISABLED'\n\n/**\n * Compile-time exhaustiveness guard over `ContactFormMode`.\n *\n * `Missing` is constrained to `never` and DEFAULTS to the contract modes that\n * are neither accepted nor explicitly declined, so a default violating its own\n * constraint breaks the build the moment `@reblu/site-contracts` gains a mode.\n *\n * At runtime a new mode already fails closed, which is correct but SILENT — and\n * silence is how a tenant switches their form on in nexus and watches nothing\n * appear. This forces a human to make the call in a diff instead.\n *\n * Exported so it is never reported as an unused symbol.\n */\nexport type ContactFormModeExhaustiveCheck<\n Missing extends never = Exclude<ContactFormMode, ContactFormOperationalMode | DeclinedMode>,\n> = Missing\n\n/** Why the form is not being shown. Drives diagnostics, never visitor-facing copy. */\nexport type ContactFormUnavailableReason =\n /** The analytics read failed or was never made — no answer at all. */\n | 'unreadable'\n /** The tenant's mode is not one base-api accepts submissions in. */\n | 'mode-not-operational'\n /** Accepting mode, but no site key: the browser cannot mint a token. */\n | 'missing-recaptcha-key'\n /** A site key is present but cannot be a key — see {@link RECAPTCHA_SITE_KEY_PATTERN}. */\n | 'malformed-recaptcha-key'\n\nexport type ContactFormAvailability =\n /**\n * Confirmed: base-api accepts submissions AND a usable site key exists. The\n * key travels inside the `available` case on purpose — a component that has\n * proven the form may render should not have to re-read (and re-null-check)\n * the payload to find out what to render it WITH.\n */\n | { state: 'available'; mode: ContactFormOperationalMode; recaptchaSiteKey: string }\n | { state: 'unavailable'; reason: ContactFormUnavailableReason }\n\n/** Whitespace-only is absent — a key someone saved blank is not a key. */\nfunction trimmed(value: unknown): string | null {\n if (typeof value !== 'string') return null\n const text = value.trim()\n return text.length > 0 ? text : null\n}\n\nfunction isOperationalMode(mode: unknown): mode is ContactFormOperationalMode {\n // Widened to `unknown` deliberately: see isSafeRecaptchaSiteKey. A base-api\n // newer than this SDK sends a string TypeScript has never heard of, and it\n // must fail closed rather than be assumed accepting.\n return (CONTACT_FORM_OPERATIONAL_MODES as readonly string[]).includes(mode as string)\n}\n\n/**\n * Resolve whether a contact form may be rendered for this tenant.\n *\n * We offer what we can CONFIRM, never what we have merely failed to disprove: an\n * unknown mode, a mode from a runtime newer than this build, and a config read\n * that threw all resolve to the same answer — no form. The page still shows the\n * phone, the email and the address, so a hidden form costs a visitor one extra\n * glance; a form that eats their message costs them the message.\n *\n * Order matters. The mode is checked BEFORE the key, because \"the tenant has not\n * switched the form on\" is the actionable half of a double failure — a site key\n * for a form nobody enabled is not the problem to fix first.\n */\nexport function resolveContactFormAvailability(\n analytics: Analytics | null | undefined,\n): ContactFormAvailability {\n if (!analytics) return { state: 'unavailable', reason: 'unreadable' }\n\n const mode = analytics.contactFormMode\n if (!isOperationalMode(mode)) return { state: 'unavailable', reason: 'mode-not-operational' }\n\n // `ContactFormInput.captcha` is REQUIRED and base-api verifies it, so an\n // accepting mode with no usable key is still a form whose every submission is\n // dead on arrival. \"Missing\" and \"malformed\" are separated because they call\n // for different fixes by different people: one owner never filled the field,\n // the other filled it with the wrong thing.\n const recaptchaSiteKey = trimmed(analytics.recaptchaSiteKey)\n if (!recaptchaSiteKey) return { state: 'unavailable', reason: 'missing-recaptcha-key' }\n if (!isSafeRecaptchaSiteKey(recaptchaSiteKey)) {\n return { state: 'unavailable', reason: 'malformed-recaptcha-key' }\n }\n\n return { state: 'available', mode, recaptchaSiteKey }\n}\n\n/** Narrowing helper — the single question a renderer should ask. */\nexport function isContactFormAvailable(\n availability: ContactFormAvailability,\n): availability is Extract<ContactFormAvailability, { state: 'available' }> {\n return availability.state === 'available'\n}\n\n/**\n * The key to hand `GoogleReCaptchaProvider` on this render — `''` until `armed`.\n *\n * ─────────────────────────────────────────────────────────────────────────────\n * WHAT THIS BUYS\n * ─────────────────────────────────────────────────────────────────────────────\n *\n * `GoogleReCaptchaProvider` loads Google's `api.js` the moment it MOUNTS. Mount\n * it with the form and every visitor who merely lands on the page to read the\n * phone number has contacted Google — on a clinic's contact page, a third-party\n * request nobody asked for, on a route whose whole subject is personal data.\n *\n * Arming on first interaction with the form (focus, first keystroke) means\n * Google is contacted only for a visitor who is actually about to submit, which\n * is the only visitor whose token is worth anything. It is a smaller page, a\n * faster one, and a materially smaller consent surface.\n *\n * **This is NOT a cookie-consent implementation.** Deferring the script narrows\n * the exposure; it does not gate it on a lawful basis. Full consent gating for\n * third parties is REB-642 and has its own owner.\n *\n * ─────────────────────────────────────────────────────────────────────────────\n * HOW, WITHOUT A HACK — MEASURED, NOT ASSUMED\n * ─────────────────────────────────────────────────────────────────────────────\n *\n * `react-google-recaptcha-v3` injects the script from an effect shaped\n *\n * useEffect(() => { if (reCaptchaKey) { …inject…; return cleanup } … },\n * [ …, reCaptchaKey, … ])\n *\n * Two facts fall out, and together they are the whole mechanism:\n *\n * 1. An EMPTY key mounts the provider and injects NOTHING.\n * 2. `reCaptchaKey` is a dependency, so swapping the real key in later re-runs\n * the effect and injects the script then.\n *\n * Crucially the provider element never leaves the tree, so its children are\n * never remounted: the visitor does not lose focus, a half-typed word, or the\n * caret position at the instant the script arrives. This is the library's own\n * documented prop doing its documented job — no imperative script injection, no\n * reaching into the DOM, no fork.\n *\n * Both facts are pinned by `tests/recaptcha-deferred-oracle.test.tsx`, which\n * drives the REAL library and counts script tags in the DOM. They are claims\n * about someone else's code, so prose asserting them is not enough — if a future\n * version of the library injects eagerly, that test goes red and this whole\n * optimisation is known to be void.\n *\n * ── THE ONE COST, MEASURED ──\n *\n * On the empty-key render the library calls its own warn helper, which is\n * written inverted, so the warning fires in PRODUCTION and stays silent in\n * development. A visitor who opens devtools before touching a field sees one\n * `\"<GoogleReCaptchaProvider /> recaptcha key not provided\"`. Cosmetic console\n * noise, traded against a third-party request on every view of the page. Worth\n * it, and recorded here so the next reader knows it is expected.\n *\n * ── AND NO FALLBACK KEY, EVER ──\n *\n * `''` for a blank OR malformed key however armed. There is no hardcoded\n * fallback anywhere in this module, because a captcha key that is not the\n * tenant's fails Google's verification while LOOKING configured — converting a\n * visible gap an owner can fix in nexus into a silent one nobody can.\n * {@link resolveContactFormAvailability} hides the form in that case; this is\n * the second lock on the same door.\n */\nexport function deferredRecaptchaKey(siteKey: string, armed: boolean): string {\n if (!armed) return ''\n const key = trimmed(siteKey)\n return key && isSafeRecaptchaSiteKey(key) ? key : ''\n}\n"],"mappings":";AAyEO,IAAM,mBAAmB;AAiCzB,IAAM,6BAA6B;AAQnC,SAAS,uBAAuB,OAAiC;AACtE,SAAO,OAAO,UAAU,YAAY,2BAA2B,KAAK,KAAK;AAC3E;AAiBO,IAAM,iCAAiC,CAAC,WAAW,WAAW,QAAQ;AAoD7E,SAAS,QAAQ,OAA+B;AAC9C,MAAI,OAAO,UAAU,SAAU,QAAO;AACtC,QAAM,OAAO,MAAM,KAAK;AACxB,SAAO,KAAK,SAAS,IAAI,OAAO;AAClC;AAEA,SAAS,kBAAkB,MAAmD;AAI5E,SAAQ,+BAAqD,SAAS,IAAc;AACtF;AAeO,SAAS,+BACd,WACyB;AACzB,MAAI,CAAC,UAAW,QAAO,EAAE,OAAO,eAAe,QAAQ,aAAa;AAEpE,QAAM,OAAO,UAAU;AACvB,MAAI,CAAC,kBAAkB,IAAI,EAAG,QAAO,EAAE,OAAO,eAAe,QAAQ,uBAAuB;AAO5F,QAAM,mBAAmB,QAAQ,UAAU,gBAAgB;AAC3D,MAAI,CAAC,iBAAkB,QAAO,EAAE,OAAO,eAAe,QAAQ,wBAAwB;AACtF,MAAI,CAAC,uBAAuB,gBAAgB,GAAG;AAC7C,WAAO,EAAE,OAAO,eAAe,QAAQ,0BAA0B;AAAA,EACnE;AAEA,SAAO,EAAE,OAAO,aAAa,MAAM,iBAAiB;AACtD;AAGO,SAAS,uBACd,cAC0E;AAC1E,SAAO,aAAa,UAAU;AAChC;AAoEO,SAAS,qBAAqB,SAAiB,OAAwB;AAC5E,MAAI,CAAC,MAAO,QAAO;AACnB,QAAM,MAAM,QAAQ,OAAO;AAC3B,SAAO,OAAO,uBAAuB,GAAG,IAAI,MAAM;AACpD;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reblu/site-next",
3
- "version": "1.5.1",
3
+ "version": "1.6.0",
4
4
  "description": "Next.js RSC glue for Reblu SITEs — cache + CTA + auth server/client components.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Reblu — Adolfo Unturbe",
@@ -100,6 +100,16 @@
100
100
  "types": "./dist/analytics.d.cts",
101
101
  "default": "./dist/analytics.cjs"
102
102
  }
103
+ },
104
+ "./recaptcha": {
105
+ "import": {
106
+ "types": "./dist/recaptcha.d.ts",
107
+ "default": "./dist/recaptcha.js"
108
+ },
109
+ "require": {
110
+ "types": "./dist/recaptcha.d.cts",
111
+ "default": "./dist/recaptcha.cjs"
112
+ }
103
113
  }
104
114
  },
105
115
  "publishConfig": {
@@ -113,7 +123,7 @@
113
123
  "dependencies": {
114
124
  "@reblu/site-client": "^4.0.0",
115
125
  "@reblu/site-contracts": "^0.5.0",
116
- "@reblu/site-ui": "^0.4.0"
126
+ "@reblu/site-ui": "^0.5.0"
117
127
  },
118
128
  "devDependencies": {
119
129
  "@testing-library/jest-dom": "^6.9.1",
@@ -128,6 +138,7 @@
128
138
  "next-themes": "^0.4.6",
129
139
  "react": "19.2.4",
130
140
  "react-dom": "19.2.4",
141
+ "react-google-recaptcha-v3": "^1.11.0",
131
142
  "tsup": "^8.5.0",
132
143
  "typescript": "^5.8.3",
133
144
  "vitest": "^4.0.15",