@reblu/site-next 1.9.0 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/README.md +65 -19
- package/dist/consent-server.cjs +88 -0
- package/dist/consent-server.cjs.map +1 -0
- package/dist/consent-server.d.cts +22 -0
- package/dist/consent-server.d.ts +22 -0
- package/dist/consent-server.js +58 -0
- package/dist/consent-server.js.map +1 -0
- package/dist/consent-shared-DbdhEXW2.d.cts +71 -0
- package/dist/consent-shared-DbdhEXW2.d.ts +71 -0
- package/dist/consent.cjs +404 -0
- package/dist/consent.cjs.map +1 -0
- package/dist/consent.d.cts +172 -0
- package/dist/consent.d.ts +172 -0
- package/dist/consent.js +362 -0
- package/dist/consent.js.map +1 -0
- package/dist/recaptcha-provider.cjs +86 -0
- package/dist/recaptcha-provider.cjs.map +1 -0
- package/dist/recaptcha-provider.d.cts +41 -0
- package/dist/recaptcha-provider.d.ts +41 -0
- package/dist/recaptcha-provider.js +68 -0
- package/dist/recaptcha-provider.js.map +1 -0
- package/dist/recaptcha.cjs.map +1 -1
- package/dist/recaptcha.d.cts +33 -4
- package/dist/recaptcha.d.ts +33 -4
- package/dist/recaptcha.js.map +1 -1
- package/package.json +42 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# @reblu/site-next
|
|
2
2
|
|
|
3
|
+
## 1.11.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 604d464: Add `@reblu/site-next/recaptcha-provider` (REB-793): `<DeferredRecaptchaProvider>` and `useDeferredRecaptcha()` fix a defect in the REB-588 deferred-load pattern where wiring `deferredRecaptchaKey`'s return value directly into `<GoogleReCaptchaProvider reCaptchaKey={...}>` loaded Google's script with the correct site key but kept calling `executeRecaptcha` with the stale empty key from before arming — `react-google-recaptcha-v3@1.11.0` captures `reCaptchaKey` in a `useRef` on first mount and never re-reads it for invisible v3. Every token mint threw, so every contact-form submission silently failed to reach base-api. `DeferredRecaptchaProvider` applies the same validated key as both the `key` and the `reCaptchaKey` prop, forcing a full remount of the vendor provider on arm instead of a prop swap, while republishing `executeRecaptcha` through a stable context so the SITE's own form (and any in-flight submit) never remounts. `deferredRecaptchaKey` (`./recaptcha`) stays exported and unchanged — now documented `@deprecated` for direct use with `<GoogleReCaptchaProvider>` — and `react-google-recaptcha-v3` remains the consuming SITE's own optional peer dependency, never bundled into the SDK.
|
|
8
|
+
|
|
9
|
+
## 1.10.0
|
|
10
|
+
|
|
11
|
+
### Minor Changes
|
|
12
|
+
|
|
13
|
+
- 2e53bf9: Add `@reblu/site-next/consent` and `@reblu/site-next/consent-server` (REB-642): a cookie-consent mechanism for Reblu SITEs — `<ConsentProvider>`/`useConsent()`, `<ConsentGate>`, `<CookieBanner>` (accept/reject/configure, three equal-weight buttons + an accessible preferences dialog), `<CookiePreferencesTrigger>` for permanent withdrawal, and `<ConsentedAnalytics>` wrapping `<AnalyticsScripts>` behind the `analytics` category. Every non-necessary category defaults to denied, and a stored choice is re-asked automatically when the tenant's cookie-policy content hash changes. `./consent-server` is a dependency-free, isomorphic module (cookie name, category taxonomy, fail-closed cookie parsing, `cookiePolicyVersion()` content hash).
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- Updated dependencies [34b15c4]
|
|
18
|
+
- @reblu/site-contracts@1.2.0
|
|
19
|
+
|
|
3
20
|
## 1.9.0
|
|
4
21
|
|
|
5
22
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -15,9 +15,10 @@ 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
|
|
19
|
-
captcha contract: the canonical action, the render
|
|
20
|
-
|
|
18
|
+
5. **reCAPTCHA v3** (`./recaptcha`, `./recaptcha-provider`) — the SITE's half
|
|
19
|
+
of the contact-form captcha contract: the canonical action, the render
|
|
20
|
+
gate, and a defect-fixed (REB-793) interaction-mount provider,
|
|
21
|
+
`<DeferredRecaptchaProvider>`. See "reCAPTCHA v3" below.
|
|
21
22
|
6. **Academy** (`./academy`, `./academy-server`) — `AcademyEnrollmentForm`,
|
|
22
23
|
the anonymous enrollment-request form, plus `deriveValidationFields` to
|
|
23
24
|
build its per-tenant validation fields from course/config data. See
|
|
@@ -29,7 +30,9 @@ intentionally Next-coupled so the lower SDK layers —
|
|
|
29
30
|
pnpm add @reblu/site-next @reblu/site-client @reblu/site-contracts @reblu/site-ui next-themes
|
|
30
31
|
```
|
|
31
32
|
|
|
32
|
-
Peer deps: `react >=19`, `next >=16`, `@reblu/site-ui`, `next-themes
|
|
33
|
+
Peer deps: `react >=19`, `next >=16`, `@reblu/site-ui`, `next-themes`, and
|
|
34
|
+
optionally `react-google-recaptcha-v3` (only if you use
|
|
35
|
+
`@reblu/site-next/recaptcha-provider` — see "reCAPTCHA v3" below).
|
|
33
36
|
|
|
34
37
|
`next-themes` is required by the `./auth` entry as a whole, not only by
|
|
35
38
|
`AuthLogo`: `tsup.config.ts` builds `auth.js`/`auth.cjs` as ONE
|
|
@@ -328,33 +331,76 @@ the way out of `/api/site/analytics`, or in the response contract.
|
|
|
328
331
|
### Load Google only when someone actually uses the form
|
|
329
332
|
|
|
330
333
|
```tsx
|
|
331
|
-
import { RECAPTCHA_ACTION
|
|
334
|
+
import { RECAPTCHA_ACTION } from '@reblu/site-next/recaptcha'
|
|
335
|
+
import { DeferredRecaptchaProvider, useDeferredRecaptcha } from '@reblu/site-next/recaptcha-provider'
|
|
332
336
|
|
|
333
337
|
const [armed, setArmed] = useState(false)
|
|
334
338
|
|
|
335
|
-
<
|
|
339
|
+
<DeferredRecaptchaProvider siteKey={siteKey} armed={armed}>
|
|
336
340
|
<form onFocusCapture={() => setArmed(true)}>…</form>
|
|
337
|
-
</
|
|
341
|
+
</DeferredRecaptchaProvider>
|
|
338
342
|
|
|
339
|
-
//
|
|
343
|
+
// inside the form, instead of react-google-recaptcha-v3's own useGoogleReCaptcha():
|
|
344
|
+
const { executeRecaptcha } = useDeferredRecaptcha()
|
|
345
|
+
// at submit: await executeRecaptcha?.(RECAPTCHA_ACTION)
|
|
340
346
|
```
|
|
341
347
|
|
|
342
348
|
`GoogleReCaptchaProvider` loads `api.js` the moment it mounts, so mounting it
|
|
343
|
-
with the form means every visitor who merely lands on the page to read the
|
|
344
|
-
number has contacted Google. `
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
`
|
|
351
|
-
`
|
|
352
|
-
|
|
353
|
-
|
|
349
|
+
with the form means every visitor who merely lands on the page to read the
|
|
350
|
+
phone number has contacted Google. `DeferredRecaptchaProvider` withholds a real
|
|
351
|
+
key until `armed`, exactly like `deferredRecaptchaKey` (below) did — but see
|
|
352
|
+
**REB-793** for why it does not stop there.
|
|
353
|
+
|
|
354
|
+
> **REB-793 — do not wire `deferredRecaptchaKey` straight into
|
|
355
|
+
> `<GoogleReCaptchaProvider>`.** That was this section's guidance before
|
|
356
|
+
> REB-793 and it is a confirmed defect: `react-google-recaptcha-v3@1.11.0`
|
|
357
|
+
> captures `reCaptchaKey` in a `useRef` on the FIRST render and only ever
|
|
358
|
+
> reassigns it through a callback the library wires when a visible widget
|
|
359
|
+
> `container` is configured — invisible v3 (no `container`, the only mode any
|
|
360
|
+
> Reblu SITE uses) never triggers that path. Swapping the prop from `''` to
|
|
361
|
+
> the real key therefore loads `api.js` with the correct key while
|
|
362
|
+
> `executeRecaptcha` keeps sending `''` to `grecaptcha.execute` forever —
|
|
363
|
+
> every token mint throws, every submission silently dies. Found 29-Sep in the
|
|
364
|
+
> first real contact-form test on dermamedicin preprod: zero requests reached
|
|
365
|
+
> `/api/site/contact-form`. `deferredRecaptchaKey` stays exported (see its own
|
|
366
|
+
> `@deprecated` doc) because `DeferredRecaptchaProvider` calls it internally
|
|
367
|
+
> and removing it would be a breaking change — but do not call it directly
|
|
368
|
+
> against `<GoogleReCaptchaProvider>` yourself.
|
|
369
|
+
|
|
370
|
+
`DeferredRecaptchaProvider` fixes this by applying that same validated key as
|
|
371
|
+
BOTH the `key` prop and the `reCaptchaKey` prop on the vendor's
|
|
372
|
+
`GoogleReCaptchaProvider`. A `key` change forces React to unmount the
|
|
373
|
+
empty-key instance (which never loaded anything) and mount a brand-new one
|
|
374
|
+
whose `useRef` captures the real key from its very first render — never a
|
|
375
|
+
stale prop swap on a live instance. That remount is contained to an internal,
|
|
376
|
+
invisible bridge component; **the SITE's own `children` — the form — never
|
|
377
|
+
remounts**, because they render as a sibling under a separate, stable context
|
|
378
|
+
that `useDeferredRecaptcha()` reads. A visitor's typed fields, and an
|
|
379
|
+
in-flight submit awaiting `executeRecaptcha` (demo-site's own arm-on-submit
|
|
380
|
+
fallback for a visitor who tabs straight to the button), both survive arming.
|
|
381
|
+
|
|
382
|
+
Both the vendor's mount-vs-inject behaviour and this fix's own claims about
|
|
383
|
+
key-vs-execute correctness are pinned by real end-to-end tests, not prose:
|
|
384
|
+
`tests/recaptcha-deferred-oracle.test.tsx` drives the real
|
|
385
|
+
`react-google-recaptcha-v3` and counts script tags in the DOM (the original
|
|
386
|
+
REB-588 oracle); `tests/recaptcha-provider.test.tsx` goes one step further and
|
|
387
|
+
actually calls `executeRecaptcha`, asserting `grecaptcha.execute` was invoked
|
|
388
|
+
with the ARMED site key — including a negative control that reproduces the
|
|
389
|
+
REB-793 bug directly against the vendor library, so this suite is proven to
|
|
390
|
+
catch it if it ever comes back. If a future library version injects eagerly,
|
|
391
|
+
or stops honoring `key`-based remounts, these tests go red rather than this
|
|
392
|
+
README quietly becoming false.
|
|
354
393
|
|
|
355
394
|
**This is not cookie consent.** Deferring the script narrows the exposure; it
|
|
356
395
|
does not gate it on a lawful basis. That is REB-642.
|
|
357
396
|
|
|
397
|
+
`DeferredRecaptchaProvider`/`useDeferredRecaptcha` live in their own subpath
|
|
398
|
+
(`./recaptcha-provider`), separate from `./recaptcha` above, because they
|
|
399
|
+
import React and `react-google-recaptcha-v3` — see "Which half lives where" —
|
|
400
|
+
so `react-google-recaptcha-v3` is an OPTIONAL peer dependency of this package:
|
|
401
|
+
install it yourself alongside `@reblu/site-next` (it already is, if you
|
|
402
|
+
followed "Install" above).
|
|
403
|
+
|
|
358
404
|
`RECAPTCHA_ACTION` is exported because reCAPTCHA v3 scores **per action**: two
|
|
359
405
|
SITEs calling `executeRecaptcha('submit_form')` and `'contact_form'` build two
|
|
360
406
|
unrelated score histories for the same feature.
|
|
@@ -0,0 +1,88 @@
|
|
|
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/consent-server.ts
|
|
21
|
+
var consent_server_exports = {};
|
|
22
|
+
__export(consent_server_exports, {
|
|
23
|
+
CONSENT_CATEGORIES: () => CONSENT_CATEGORIES,
|
|
24
|
+
CONSENT_COOKIE_NAME: () => CONSENT_COOKIE_NAME,
|
|
25
|
+
DEFAULT_CONSENT_CATEGORIES: () => DEFAULT_CONSENT_CATEGORIES,
|
|
26
|
+
cookiePolicyVersion: () => cookiePolicyVersion,
|
|
27
|
+
parseConsentCookie: () => parseConsentCookie,
|
|
28
|
+
serializeConsentCookie: () => serializeConsentCookie
|
|
29
|
+
});
|
|
30
|
+
module.exports = __toCommonJS(consent_server_exports);
|
|
31
|
+
var import_node_crypto = require("crypto");
|
|
32
|
+
|
|
33
|
+
// src/consent-shared.ts
|
|
34
|
+
var CONSENT_COOKIE_NAME = "reblu_consent";
|
|
35
|
+
var CONSENT_CATEGORIES = ["analytics", "marketing", "preferences"];
|
|
36
|
+
var DEFAULT_CONSENT_CATEGORIES = Object.freeze(
|
|
37
|
+
CONSENT_CATEGORIES.reduce(
|
|
38
|
+
(state, category) => ({ ...state, [category]: false }),
|
|
39
|
+
{}
|
|
40
|
+
)
|
|
41
|
+
);
|
|
42
|
+
function isPlainObject(value) {
|
|
43
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
44
|
+
}
|
|
45
|
+
function parseCategories(value) {
|
|
46
|
+
if (!isPlainObject(value)) return null;
|
|
47
|
+
const result = {};
|
|
48
|
+
for (const category of CONSENT_CATEGORIES) {
|
|
49
|
+
const raw = value[category];
|
|
50
|
+
if (typeof raw !== "boolean") return null;
|
|
51
|
+
result[category] = raw;
|
|
52
|
+
}
|
|
53
|
+
return result;
|
|
54
|
+
}
|
|
55
|
+
function parseConsentCookie(raw) {
|
|
56
|
+
if (!raw) return null;
|
|
57
|
+
let json;
|
|
58
|
+
try {
|
|
59
|
+
json = JSON.parse(raw);
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
if (!isPlainObject(json)) return null;
|
|
64
|
+
const categories = parseCategories(json.categories);
|
|
65
|
+
if (!categories) return null;
|
|
66
|
+
const policyVersion = json.policyVersion;
|
|
67
|
+
if (policyVersion !== null && typeof policyVersion !== "string") return null;
|
|
68
|
+
return { categories, policyVersion };
|
|
69
|
+
}
|
|
70
|
+
function serializeConsentCookie(payload) {
|
|
71
|
+
return JSON.stringify(payload);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// src/consent-server.ts
|
|
75
|
+
function cookiePolicyVersion(policy) {
|
|
76
|
+
if (!policy) return null;
|
|
77
|
+
return (0, import_node_crypto.createHash)("sha256").update(policy, "utf8").digest("hex").slice(0, 12);
|
|
78
|
+
}
|
|
79
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
80
|
+
0 && (module.exports = {
|
|
81
|
+
CONSENT_CATEGORIES,
|
|
82
|
+
CONSENT_COOKIE_NAME,
|
|
83
|
+
DEFAULT_CONSENT_CATEGORIES,
|
|
84
|
+
cookiePolicyVersion,
|
|
85
|
+
parseConsentCookie,
|
|
86
|
+
serializeConsentCookie
|
|
87
|
+
});
|
|
88
|
+
//# sourceMappingURL=consent-server.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/consent-server.ts","../src/consent-shared.ts"],"sourcesContent":["import { createHash } from 'node:crypto'\n\nimport {\n CONSENT_CATEGORIES,\n CONSENT_COOKIE_NAME,\n DEFAULT_CONSENT_CATEGORIES,\n parseConsentCookie,\n serializeConsentCookie,\n type ConsentCategory,\n type ConsentCategoryState,\n type ConsentCookiePayload,\n} from './consent-shared'\n\n/**\n * `@reblu/site-next/consent-server` — the full server-side surface of cookie\n * consent (REB-642): everything in `./consent-shared` (cookie name, category\n * taxonomy, fail-closed parse/serialise), PLUS `cookiePolicyVersion()`, the\n * one function in this whole mechanism that needs a real dependency\n * (`node:crypto`, a Node builtin — not an npm package).\n *\n * This is the PUBLIC subpath (`@reblu/site-next/consent-server`) — a SITE's\n * server-side code (Route Handler, Server Component) imports from here.\n * `./consent-shared` re-exported below is intentionally the ONLY thing the\n * CLIENT entry (`./consent`) imports from; see that file's doc comment for\n * why `cookiePolicyVersion` and its `node:crypto` import must never be\n * reachable from a client component, even transitively.\n */\n\nexport {\n CONSENT_CATEGORIES,\n CONSENT_COOKIE_NAME,\n DEFAULT_CONSENT_CATEGORIES,\n parseConsentCookie,\n serializeConsentCookie,\n type ConsentCategory,\n type ConsentCategoryState,\n type ConsentCookiePayload,\n}\n\n/**\n * A content hash of the tenant's cookie policy, standing in for a stored\n * version string (REB-642 §7.2). A hash cannot drift the way a manually\n * bumped version number can: a tenant editing the policy text automatically\n * produces a new version, and `./consent`'s provider re-asks consent the\n * moment the version it holds no longer matches. `null` in, `null` out — a\n * tenant with no cookie policy stored has no version to pin.\n *\n * SERVER-ONLY: a SITE computes this once (from `SiteSettings.cookiePolicy`)\n * and passes the resulting string to `<ConsentProvider policyVersion>` as a\n * plain prop. Nothing under `@reblu/site-next/consent` calls this function or\n * imports `node:crypto` — enforced by `tests/consent-subpath-isolation.test.ts`.\n *\n * First 12 hex characters of SHA-256, matching the length asserted by this\n * function's own test and short enough to store cheaply alongside the\n * categories in the cookie.\n */\nexport function cookiePolicyVersion(policy: string | null | undefined): string | null {\n if (!policy) return null\n return createHash('sha256').update(policy, 'utf8').digest('hex').slice(0, 12)\n}\n","/**\n * `../consent-shared` — the truly isomorphic core of cookie consent (REB-642):\n * the cookie name, the category taxonomy, and fail-closed parse/serialise.\n * ZERO dependencies, including Node builtins — this is the one thing that\n * matters about this file's existence.\n *\n * ─────────────────────────────────────────────────────────────────────────────\n * WHY THIS IS A SEPARATE FILE FROM `./consent-server.ts`\n * ─────────────────────────────────────────────────────────────────────────────\n *\n * `./consent` (the client entry) needs `CONSENT_CATEGORIES` and friends, and\n * imports them straight from here. `./consent-server.ts` ALSO needs them, plus\n * `cookiePolicyVersion()`, which needs `node:crypto`. Those two facts collide\n * under this package's build rules: `tsup.config.ts` runs with\n * `treeshake: false` — REQUIRED to keep the `'use client'` prologue on the\n * `./consent` entry from being stripped (see the R4 note there) — and with\n * tree-shaking off, esbuild bundles an imported FILE WHOLESALE, not just the\n * specific binding a module asked for. So if `cookiePolicyVersion` (and its\n * `node:crypto` import) lived in the SAME file as `CONSENT_CATEGORIES`, every\n * client component that only wanted the category list would drag `node:crypto`\n * into the browser bundle too — dead weight at best, a broken build on a\n * bundler stricter than tsup about Node builtins in client code at worst. This\n * was caught live: `tests/consent-subpath-isolation.test.ts` asserts `./consent`\n * never references `crypto`, and failed until this split existed.\n *\n * `./consent-server.ts` re-exports everything here, so\n * `@reblu/site-next/consent-server` keeps serving the full public surface —\n * this split is purely internal.\n *\n * A cookie is attacker- and drift-controlled input: a visitor can edit it in\n * devtools, an older SDK version can leave a differently-shaped payload\n * behind, and a proxy or extension can mangle it in transit.\n * `parseConsentCookie` never coerces a doubtful value toward \"granted\" —\n * anything that is not EXACTLY the expected shape (an object, every known\n * category present and a strict boolean, `policyVersion` a string or `null`)\n * resolves to `null`, which `./consent`'s provider treats identically to \"no\n * cookie yet\": ask again, default every category denied. Silently granting a\n * truthy-but-malformed value is the one mistake this module is built to make\n * impossible.\n */\n\n/** The first-party cookie a visitor's consent choice is stored in. */\nexport const CONSENT_COOKIE_NAME = 'reblu_consent'\n\n/**\n * The fixed, non-tenant-editable category taxonomy (REB-642 §7.1): \"If each\n * SITE invents its own categories, no two clinics can be audited the same\n * way.\" `necessary` is deliberately NOT a member — it is not a choice, it has\n * no toggle, and it is never read from or written to the cookie.\n */\nexport const CONSENT_CATEGORIES = ['analytics', 'marketing', 'preferences'] as const\n\n/** One of the fixed consent categories. */\nexport type ConsentCategory = (typeof CONSENT_CATEGORIES)[number]\n\n/** A visitor's granted/denied state for every optional category. */\nexport type ConsentCategoryState = Record<ConsentCategory, boolean>\n\n/** The full payload stored in the consent cookie. */\nexport interface ConsentCookiePayload {\n categories: ConsentCategoryState\n /** The `cookiePolicyVersion()` in force when this choice was recorded, or `null`. */\n policyVersion: string | null\n}\n\n/** Every optional category denied — the only safe default. */\nexport const DEFAULT_CONSENT_CATEGORIES: Readonly<ConsentCategoryState> = Object.freeze(\n CONSENT_CATEGORIES.reduce(\n (state, category) => ({ ...state, [category]: false }),\n {} as ConsentCategoryState,\n ),\n)\n\nfunction isPlainObject(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\n/**\n * Reads and validates the categories object, requiring every known category to\n * be present as a STRICT boolean. Returns `null` on any deviation — a missing\n * category, an extra one being the only thing wrong is fine (ignored, never\n * leaked into the result), but a category that is present and not `true`/`false`\n * literally, or one that is simply absent, fails the whole parse closed. A\n * partial payload does NOT default the missing categories to `false`: that\n * would make \"a payload half-stripped by a proxy\" indistinguishable from \"a\n * visitor who explicitly denied the rest\", which is not a distinction this\n * function is in a position to make safely.\n */\nfunction parseCategories(value: unknown): ConsentCategoryState | null {\n if (!isPlainObject(value)) return null\n\n const result = {} as ConsentCategoryState\n for (const category of CONSENT_CATEGORIES) {\n const raw = value[category]\n if (typeof raw !== 'boolean') return null\n result[category] = raw\n }\n return result\n}\n\n/**\n * Parses a raw consent cookie value into a validated payload, or `null` if the\n * value is absent or does not match the expected shape exactly. See the module\n * doc for why this fails closed rather than coercing.\n */\nexport function parseConsentCookie(raw: string | null | undefined): ConsentCookiePayload | null {\n if (!raw) return null\n\n let json: unknown\n try {\n json = JSON.parse(raw)\n } catch {\n return null\n }\n\n if (!isPlainObject(json)) return null\n\n const categories = parseCategories(json.categories)\n if (!categories) return null\n\n const policyVersion = json.policyVersion\n if (policyVersion !== null && typeof policyVersion !== 'string') return null\n\n return { categories, policyVersion }\n}\n\n/** Serialises a consent payload for storage in the cookie. */\nexport function serializeConsentCookie(payload: ConsentCookiePayload): string {\n return JSON.stringify(payload)\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,yBAA2B;;;AC0CpB,IAAM,sBAAsB;AAQ5B,IAAM,qBAAqB,CAAC,aAAa,aAAa,aAAa;AAgBnE,IAAM,6BAA6D,OAAO;AAAA,EAC/E,mBAAmB;AAAA,IACjB,CAAC,OAAO,cAAc,EAAE,GAAG,OAAO,CAAC,QAAQ,GAAG,MAAM;AAAA,IACpD,CAAC;AAAA,EACH;AACF;AAEA,SAAS,cAAc,OAAkD;AACvE,SAAO,OAAO,UAAU,YAAY,UAAU,QAAQ,CAAC,MAAM,QAAQ,KAAK;AAC5E;AAaA,SAAS,gBAAgB,OAA6C;AACpE,MAAI,CAAC,cAAc,KAAK,EAAG,QAAO;AAElC,QAAM,SAAS,CAAC;AAChB,aAAW,YAAY,oBAAoB;AACzC,UAAM,MAAM,MAAM,QAAQ;AAC1B,QAAI,OAAO,QAAQ,UAAW,QAAO;AACrC,WAAO,QAAQ,IAAI;AAAA,EACrB;AACA,SAAO;AACT;AAOO,SAAS,mBAAmB,KAA6D;AAC9F,MAAI,CAAC,IAAK,QAAO;AAEjB,MAAI;AACJ,MAAI;AACF,WAAO,KAAK,MAAM,GAAG;AAAA,EACvB,QAAQ;AACN,WAAO;AAAA,EACT;AAEA,MAAI,CAAC,cAAc,IAAI,EAAG,QAAO;AAEjC,QAAM,aAAa,gBAAgB,KAAK,UAAU;AAClD,MAAI,CAAC,WAAY,QAAO;AAExB,QAAM,gBAAgB,KAAK;AAC3B,MAAI,kBAAkB,QAAQ,OAAO,kBAAkB,SAAU,QAAO;AAExE,SAAO,EAAE,YAAY,cAAc;AACrC;AAGO,SAAS,uBAAuB,SAAuC;AAC5E,SAAO,KAAK,UAAU,OAAO;AAC/B;;;ADzEO,SAAS,oBAAoB,QAAkD;AACpF,MAAI,CAAC,OAAQ,QAAO;AACpB,aAAO,+BAAW,QAAQ,EAAE,OAAO,QAAQ,MAAM,EAAE,OAAO,KAAK,EAAE,MAAM,GAAG,EAAE;AAC9E;","names":[]}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export { C as CONSENT_CATEGORIES, a as CONSENT_COOKIE_NAME, b as ConsentCategory, c as ConsentCategoryState, d as ConsentCookiePayload, D as DEFAULT_CONSENT_CATEGORIES, p as parseConsentCookie, s as serializeConsentCookie } from './consent-shared-DbdhEXW2.cjs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A content hash of the tenant's cookie policy, standing in for a stored
|
|
5
|
+
* version string (REB-642 §7.2). A hash cannot drift the way a manually
|
|
6
|
+
* bumped version number can: a tenant editing the policy text automatically
|
|
7
|
+
* produces a new version, and `./consent`'s provider re-asks consent the
|
|
8
|
+
* moment the version it holds no longer matches. `null` in, `null` out — a
|
|
9
|
+
* tenant with no cookie policy stored has no version to pin.
|
|
10
|
+
*
|
|
11
|
+
* SERVER-ONLY: a SITE computes this once (from `SiteSettings.cookiePolicy`)
|
|
12
|
+
* and passes the resulting string to `<ConsentProvider policyVersion>` as a
|
|
13
|
+
* plain prop. Nothing under `@reblu/site-next/consent` calls this function or
|
|
14
|
+
* imports `node:crypto` — enforced by `tests/consent-subpath-isolation.test.ts`.
|
|
15
|
+
*
|
|
16
|
+
* First 12 hex characters of SHA-256, matching the length asserted by this
|
|
17
|
+
* function's own test and short enough to store cheaply alongside the
|
|
18
|
+
* categories in the cookie.
|
|
19
|
+
*/
|
|
20
|
+
declare function cookiePolicyVersion(policy: string | null | undefined): string | null;
|
|
21
|
+
|
|
22
|
+
export { cookiePolicyVersion };
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export { C as CONSENT_CATEGORIES, a as CONSENT_COOKIE_NAME, b as ConsentCategory, c as ConsentCategoryState, d as ConsentCookiePayload, D as DEFAULT_CONSENT_CATEGORIES, p as parseConsentCookie, s as serializeConsentCookie } from './consent-shared-DbdhEXW2.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A content hash of the tenant's cookie policy, standing in for a stored
|
|
5
|
+
* version string (REB-642 §7.2). A hash cannot drift the way a manually
|
|
6
|
+
* bumped version number can: a tenant editing the policy text automatically
|
|
7
|
+
* produces a new version, and `./consent`'s provider re-asks consent the
|
|
8
|
+
* moment the version it holds no longer matches. `null` in, `null` out — a
|
|
9
|
+
* tenant with no cookie policy stored has no version to pin.
|
|
10
|
+
*
|
|
11
|
+
* SERVER-ONLY: a SITE computes this once (from `SiteSettings.cookiePolicy`)
|
|
12
|
+
* and passes the resulting string to `<ConsentProvider policyVersion>` as a
|
|
13
|
+
* plain prop. Nothing under `@reblu/site-next/consent` calls this function or
|
|
14
|
+
* imports `node:crypto` — enforced by `tests/consent-subpath-isolation.test.ts`.
|
|
15
|
+
*
|
|
16
|
+
* First 12 hex characters of SHA-256, matching the length asserted by this
|
|
17
|
+
* function's own test and short enough to store cheaply alongside the
|
|
18
|
+
* categories in the cookie.
|
|
19
|
+
*/
|
|
20
|
+
declare function cookiePolicyVersion(policy: string | null | undefined): string | null;
|
|
21
|
+
|
|
22
|
+
export { cookiePolicyVersion };
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// src/consent-server.ts
|
|
2
|
+
import { createHash } from "crypto";
|
|
3
|
+
|
|
4
|
+
// src/consent-shared.ts
|
|
5
|
+
var CONSENT_COOKIE_NAME = "reblu_consent";
|
|
6
|
+
var CONSENT_CATEGORIES = ["analytics", "marketing", "preferences"];
|
|
7
|
+
var DEFAULT_CONSENT_CATEGORIES = Object.freeze(
|
|
8
|
+
CONSENT_CATEGORIES.reduce(
|
|
9
|
+
(state, category) => ({ ...state, [category]: false }),
|
|
10
|
+
{}
|
|
11
|
+
)
|
|
12
|
+
);
|
|
13
|
+
function isPlainObject(value) {
|
|
14
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
15
|
+
}
|
|
16
|
+
function parseCategories(value) {
|
|
17
|
+
if (!isPlainObject(value)) return null;
|
|
18
|
+
const result = {};
|
|
19
|
+
for (const category of CONSENT_CATEGORIES) {
|
|
20
|
+
const raw = value[category];
|
|
21
|
+
if (typeof raw !== "boolean") return null;
|
|
22
|
+
result[category] = raw;
|
|
23
|
+
}
|
|
24
|
+
return result;
|
|
25
|
+
}
|
|
26
|
+
function parseConsentCookie(raw) {
|
|
27
|
+
if (!raw) return null;
|
|
28
|
+
let json;
|
|
29
|
+
try {
|
|
30
|
+
json = JSON.parse(raw);
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
if (!isPlainObject(json)) return null;
|
|
35
|
+
const categories = parseCategories(json.categories);
|
|
36
|
+
if (!categories) return null;
|
|
37
|
+
const policyVersion = json.policyVersion;
|
|
38
|
+
if (policyVersion !== null && typeof policyVersion !== "string") return null;
|
|
39
|
+
return { categories, policyVersion };
|
|
40
|
+
}
|
|
41
|
+
function serializeConsentCookie(payload) {
|
|
42
|
+
return JSON.stringify(payload);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// src/consent-server.ts
|
|
46
|
+
function cookiePolicyVersion(policy) {
|
|
47
|
+
if (!policy) return null;
|
|
48
|
+
return createHash("sha256").update(policy, "utf8").digest("hex").slice(0, 12);
|
|
49
|
+
}
|
|
50
|
+
export {
|
|
51
|
+
CONSENT_CATEGORIES,
|
|
52
|
+
CONSENT_COOKIE_NAME,
|
|
53
|
+
DEFAULT_CONSENT_CATEGORIES,
|
|
54
|
+
cookiePolicyVersion,
|
|
55
|
+
parseConsentCookie,
|
|
56
|
+
serializeConsentCookie
|
|
57
|
+
};
|
|
58
|
+
//# sourceMappingURL=consent-server.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/consent-server.ts","../src/consent-shared.ts"],"sourcesContent":["import { createHash } from 'node:crypto'\n\nimport {\n CONSENT_CATEGORIES,\n CONSENT_COOKIE_NAME,\n DEFAULT_CONSENT_CATEGORIES,\n parseConsentCookie,\n serializeConsentCookie,\n type ConsentCategory,\n type ConsentCategoryState,\n type ConsentCookiePayload,\n} from './consent-shared'\n\n/**\n * `@reblu/site-next/consent-server` — the full server-side surface of cookie\n * consent (REB-642): everything in `./consent-shared` (cookie name, category\n * taxonomy, fail-closed parse/serialise), PLUS `cookiePolicyVersion()`, the\n * one function in this whole mechanism that needs a real dependency\n * (`node:crypto`, a Node builtin — not an npm package).\n *\n * This is the PUBLIC subpath (`@reblu/site-next/consent-server`) — a SITE's\n * server-side code (Route Handler, Server Component) imports from here.\n * `./consent-shared` re-exported below is intentionally the ONLY thing the\n * CLIENT entry (`./consent`) imports from; see that file's doc comment for\n * why `cookiePolicyVersion` and its `node:crypto` import must never be\n * reachable from a client component, even transitively.\n */\n\nexport {\n CONSENT_CATEGORIES,\n CONSENT_COOKIE_NAME,\n DEFAULT_CONSENT_CATEGORIES,\n parseConsentCookie,\n serializeConsentCookie,\n type ConsentCategory,\n type ConsentCategoryState,\n type ConsentCookiePayload,\n}\n\n/**\n * A content hash of the tenant's cookie policy, standing in for a stored\n * version string (REB-642 §7.2). A hash cannot drift the way a manually\n * bumped version number can: a tenant editing the policy text automatically\n * produces a new version, and `./consent`'s provider re-asks consent the\n * moment the version it holds no longer matches. `null` in, `null` out — a\n * tenant with no cookie policy stored has no version to pin.\n *\n * SERVER-ONLY: a SITE computes this once (from `SiteSettings.cookiePolicy`)\n * and passes the resulting string to `<ConsentProvider policyVersion>` as a\n * plain prop. Nothing under `@reblu/site-next/consent` calls this function or\n * imports `node:crypto` — enforced by `tests/consent-subpath-isolation.test.ts`.\n *\n * First 12 hex characters of SHA-256, matching the length asserted by this\n * function's own test and short enough to store cheaply alongside the\n * categories in the cookie.\n */\nexport function cookiePolicyVersion(policy: string | null | undefined): string | null {\n if (!policy) return null\n return createHash('sha256').update(policy, 'utf8').digest('hex').slice(0, 12)\n}\n","/**\n * `../consent-shared` — the truly isomorphic core of cookie consent (REB-642):\n * the cookie name, the category taxonomy, and fail-closed parse/serialise.\n * ZERO dependencies, including Node builtins — this is the one thing that\n * matters about this file's existence.\n *\n * ─────────────────────────────────────────────────────────────────────────────\n * WHY THIS IS A SEPARATE FILE FROM `./consent-server.ts`\n * ─────────────────────────────────────────────────────────────────────────────\n *\n * `./consent` (the client entry) needs `CONSENT_CATEGORIES` and friends, and\n * imports them straight from here. `./consent-server.ts` ALSO needs them, plus\n * `cookiePolicyVersion()`, which needs `node:crypto`. Those two facts collide\n * under this package's build rules: `tsup.config.ts` runs with\n * `treeshake: false` — REQUIRED to keep the `'use client'` prologue on the\n * `./consent` entry from being stripped (see the R4 note there) — and with\n * tree-shaking off, esbuild bundles an imported FILE WHOLESALE, not just the\n * specific binding a module asked for. So if `cookiePolicyVersion` (and its\n * `node:crypto` import) lived in the SAME file as `CONSENT_CATEGORIES`, every\n * client component that only wanted the category list would drag `node:crypto`\n * into the browser bundle too — dead weight at best, a broken build on a\n * bundler stricter than tsup about Node builtins in client code at worst. This\n * was caught live: `tests/consent-subpath-isolation.test.ts` asserts `./consent`\n * never references `crypto`, and failed until this split existed.\n *\n * `./consent-server.ts` re-exports everything here, so\n * `@reblu/site-next/consent-server` keeps serving the full public surface —\n * this split is purely internal.\n *\n * A cookie is attacker- and drift-controlled input: a visitor can edit it in\n * devtools, an older SDK version can leave a differently-shaped payload\n * behind, and a proxy or extension can mangle it in transit.\n * `parseConsentCookie` never coerces a doubtful value toward \"granted\" —\n * anything that is not EXACTLY the expected shape (an object, every known\n * category present and a strict boolean, `policyVersion` a string or `null`)\n * resolves to `null`, which `./consent`'s provider treats identically to \"no\n * cookie yet\": ask again, default every category denied. Silently granting a\n * truthy-but-malformed value is the one mistake this module is built to make\n * impossible.\n */\n\n/** The first-party cookie a visitor's consent choice is stored in. */\nexport const CONSENT_COOKIE_NAME = 'reblu_consent'\n\n/**\n * The fixed, non-tenant-editable category taxonomy (REB-642 §7.1): \"If each\n * SITE invents its own categories, no two clinics can be audited the same\n * way.\" `necessary` is deliberately NOT a member — it is not a choice, it has\n * no toggle, and it is never read from or written to the cookie.\n */\nexport const CONSENT_CATEGORIES = ['analytics', 'marketing', 'preferences'] as const\n\n/** One of the fixed consent categories. */\nexport type ConsentCategory = (typeof CONSENT_CATEGORIES)[number]\n\n/** A visitor's granted/denied state for every optional category. */\nexport type ConsentCategoryState = Record<ConsentCategory, boolean>\n\n/** The full payload stored in the consent cookie. */\nexport interface ConsentCookiePayload {\n categories: ConsentCategoryState\n /** The `cookiePolicyVersion()` in force when this choice was recorded, or `null`. */\n policyVersion: string | null\n}\n\n/** Every optional category denied — the only safe default. */\nexport const DEFAULT_CONSENT_CATEGORIES: Readonly<ConsentCategoryState> = Object.freeze(\n CONSENT_CATEGORIES.reduce(\n (state, category) => ({ ...state, [category]: false }),\n {} as ConsentCategoryState,\n ),\n)\n\nfunction isPlainObject(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\n/**\n * Reads and validates the categories object, requiring every known category to\n * be present as a STRICT boolean. Returns `null` on any deviation — a missing\n * category, an extra one being the only thing wrong is fine (ignored, never\n * leaked into the result), but a category that is present and not `true`/`false`\n * literally, or one that is simply absent, fails the whole parse closed. A\n * partial payload does NOT default the missing categories to `false`: that\n * would make \"a payload half-stripped by a proxy\" indistinguishable from \"a\n * visitor who explicitly denied the rest\", which is not a distinction this\n * function is in a position to make safely.\n */\nfunction parseCategories(value: unknown): ConsentCategoryState | null {\n if (!isPlainObject(value)) return null\n\n const result = {} as ConsentCategoryState\n for (const category of CONSENT_CATEGORIES) {\n const raw = value[category]\n if (typeof raw !== 'boolean') return null\n result[category] = raw\n }\n return result\n}\n\n/**\n * Parses a raw consent cookie value into a validated payload, or `null` if the\n * value is absent or does not match the expected shape exactly. See the module\n * doc for why this fails closed rather than coercing.\n */\nexport function parseConsentCookie(raw: string | null | undefined): ConsentCookiePayload | null {\n if (!raw) return null\n\n let json: unknown\n try {\n json = JSON.parse(raw)\n } catch {\n return null\n }\n\n if (!isPlainObject(json)) return null\n\n const categories = parseCategories(json.categories)\n if (!categories) return null\n\n const policyVersion = json.policyVersion\n if (policyVersion !== null && typeof policyVersion !== 'string') return null\n\n return { categories, policyVersion }\n}\n\n/** Serialises a consent payload for storage in the cookie. */\nexport function serializeConsentCookie(payload: ConsentCookiePayload): string {\n return JSON.stringify(payload)\n}\n"],"mappings":";AAAA,SAAS,kBAAkB;;;AC0CpB,IAAM,sBAAsB;AAQ5B,IAAM,qBAAqB,CAAC,aAAa,aAAa,aAAa;AAgBnE,IAAM,6BAA6D,OAAO;AAAA,EAC/E,mBAAmB;AAAA,IACjB,CAAC,OAAO,cAAc,EAAE,GAAG,OAAO,CAAC,QAAQ,GAAG,MAAM;AAAA,IACpD,CAAC;AAAA,EACH;AACF;AAEA,SAAS,cAAc,OAAkD;AACvE,SAAO,OAAO,UAAU,YAAY,UAAU,QAAQ,CAAC,MAAM,QAAQ,KAAK;AAC5E;AAaA,SAAS,gBAAgB,OAA6C;AACpE,MAAI,CAAC,cAAc,KAAK,EAAG,QAAO;AAElC,QAAM,SAAS,CAAC;AAChB,aAAW,YAAY,oBAAoB;AACzC,UAAM,MAAM,MAAM,QAAQ;AAC1B,QAAI,OAAO,QAAQ,UAAW,QAAO;AACrC,WAAO,QAAQ,IAAI;AAAA,EACrB;AACA,SAAO;AACT;AAOO,SAAS,mBAAmB,KAA6D;AAC9F,MAAI,CAAC,IAAK,QAAO;AAEjB,MAAI;AACJ,MAAI;AACF,WAAO,KAAK,MAAM,GAAG;AAAA,EACvB,QAAQ;AACN,WAAO;AAAA,EACT;AAEA,MAAI,CAAC,cAAc,IAAI,EAAG,QAAO;AAEjC,QAAM,aAAa,gBAAgB,KAAK,UAAU;AAClD,MAAI,CAAC,WAAY,QAAO;AAExB,QAAM,gBAAgB,KAAK;AAC3B,MAAI,kBAAkB,QAAQ,OAAO,kBAAkB,SAAU,QAAO;AAExE,SAAO,EAAE,YAAY,cAAc;AACrC;AAGO,SAAS,uBAAuB,SAAuC;AAC5E,SAAO,KAAK,UAAU,OAAO;AAC/B;;;ADzEO,SAAS,oBAAoB,QAAkD;AACpF,MAAI,CAAC,OAAQ,QAAO;AACpB,SAAO,WAAW,QAAQ,EAAE,OAAO,QAAQ,MAAM,EAAE,OAAO,KAAK,EAAE,MAAM,GAAG,EAAE;AAC9E;","names":[]}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `../consent-shared` — the truly isomorphic core of cookie consent (REB-642):
|
|
3
|
+
* the cookie name, the category taxonomy, and fail-closed parse/serialise.
|
|
4
|
+
* ZERO dependencies, including Node builtins — this is the one thing that
|
|
5
|
+
* matters about this file's existence.
|
|
6
|
+
*
|
|
7
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
8
|
+
* WHY THIS IS A SEPARATE FILE FROM `./consent-server.ts`
|
|
9
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
10
|
+
*
|
|
11
|
+
* `./consent` (the client entry) needs `CONSENT_CATEGORIES` and friends, and
|
|
12
|
+
* imports them straight from here. `./consent-server.ts` ALSO needs them, plus
|
|
13
|
+
* `cookiePolicyVersion()`, which needs `node:crypto`. Those two facts collide
|
|
14
|
+
* under this package's build rules: `tsup.config.ts` runs with
|
|
15
|
+
* `treeshake: false` — REQUIRED to keep the `'use client'` prologue on the
|
|
16
|
+
* `./consent` entry from being stripped (see the R4 note there) — and with
|
|
17
|
+
* tree-shaking off, esbuild bundles an imported FILE WHOLESALE, not just the
|
|
18
|
+
* specific binding a module asked for. So if `cookiePolicyVersion` (and its
|
|
19
|
+
* `node:crypto` import) lived in the SAME file as `CONSENT_CATEGORIES`, every
|
|
20
|
+
* client component that only wanted the category list would drag `node:crypto`
|
|
21
|
+
* into the browser bundle too — dead weight at best, a broken build on a
|
|
22
|
+
* bundler stricter than tsup about Node builtins in client code at worst. This
|
|
23
|
+
* was caught live: `tests/consent-subpath-isolation.test.ts` asserts `./consent`
|
|
24
|
+
* never references `crypto`, and failed until this split existed.
|
|
25
|
+
*
|
|
26
|
+
* `./consent-server.ts` re-exports everything here, so
|
|
27
|
+
* `@reblu/site-next/consent-server` keeps serving the full public surface —
|
|
28
|
+
* this split is purely internal.
|
|
29
|
+
*
|
|
30
|
+
* A cookie is attacker- and drift-controlled input: a visitor can edit it in
|
|
31
|
+
* devtools, an older SDK version can leave a differently-shaped payload
|
|
32
|
+
* behind, and a proxy or extension can mangle it in transit.
|
|
33
|
+
* `parseConsentCookie` never coerces a doubtful value toward "granted" —
|
|
34
|
+
* anything that is not EXACTLY the expected shape (an object, every known
|
|
35
|
+
* category present and a strict boolean, `policyVersion` a string or `null`)
|
|
36
|
+
* resolves to `null`, which `./consent`'s provider treats identically to "no
|
|
37
|
+
* cookie yet": ask again, default every category denied. Silently granting a
|
|
38
|
+
* truthy-but-malformed value is the one mistake this module is built to make
|
|
39
|
+
* impossible.
|
|
40
|
+
*/
|
|
41
|
+
/** The first-party cookie a visitor's consent choice is stored in. */
|
|
42
|
+
declare const CONSENT_COOKIE_NAME = "reblu_consent";
|
|
43
|
+
/**
|
|
44
|
+
* The fixed, non-tenant-editable category taxonomy (REB-642 §7.1): "If each
|
|
45
|
+
* SITE invents its own categories, no two clinics can be audited the same
|
|
46
|
+
* way." `necessary` is deliberately NOT a member — it is not a choice, it has
|
|
47
|
+
* no toggle, and it is never read from or written to the cookie.
|
|
48
|
+
*/
|
|
49
|
+
declare const CONSENT_CATEGORIES: readonly ["analytics", "marketing", "preferences"];
|
|
50
|
+
/** One of the fixed consent categories. */
|
|
51
|
+
type ConsentCategory = (typeof CONSENT_CATEGORIES)[number];
|
|
52
|
+
/** A visitor's granted/denied state for every optional category. */
|
|
53
|
+
type ConsentCategoryState = Record<ConsentCategory, boolean>;
|
|
54
|
+
/** The full payload stored in the consent cookie. */
|
|
55
|
+
interface ConsentCookiePayload {
|
|
56
|
+
categories: ConsentCategoryState;
|
|
57
|
+
/** The `cookiePolicyVersion()` in force when this choice was recorded, or `null`. */
|
|
58
|
+
policyVersion: string | null;
|
|
59
|
+
}
|
|
60
|
+
/** Every optional category denied — the only safe default. */
|
|
61
|
+
declare const DEFAULT_CONSENT_CATEGORIES: Readonly<ConsentCategoryState>;
|
|
62
|
+
/**
|
|
63
|
+
* Parses a raw consent cookie value into a validated payload, or `null` if the
|
|
64
|
+
* value is absent or does not match the expected shape exactly. See the module
|
|
65
|
+
* doc for why this fails closed rather than coercing.
|
|
66
|
+
*/
|
|
67
|
+
declare function parseConsentCookie(raw: string | null | undefined): ConsentCookiePayload | null;
|
|
68
|
+
/** Serialises a consent payload for storage in the cookie. */
|
|
69
|
+
declare function serializeConsentCookie(payload: ConsentCookiePayload): string;
|
|
70
|
+
|
|
71
|
+
export { CONSENT_CATEGORIES as C, DEFAULT_CONSENT_CATEGORIES as D, CONSENT_COOKIE_NAME as a, type ConsentCategory as b, type ConsentCategoryState as c, type ConsentCookiePayload as d, parseConsentCookie as p, serializeConsentCookie as s };
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `../consent-shared` — the truly isomorphic core of cookie consent (REB-642):
|
|
3
|
+
* the cookie name, the category taxonomy, and fail-closed parse/serialise.
|
|
4
|
+
* ZERO dependencies, including Node builtins — this is the one thing that
|
|
5
|
+
* matters about this file's existence.
|
|
6
|
+
*
|
|
7
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
8
|
+
* WHY THIS IS A SEPARATE FILE FROM `./consent-server.ts`
|
|
9
|
+
* ─────────────────────────────────────────────────────────────────────────────
|
|
10
|
+
*
|
|
11
|
+
* `./consent` (the client entry) needs `CONSENT_CATEGORIES` and friends, and
|
|
12
|
+
* imports them straight from here. `./consent-server.ts` ALSO needs them, plus
|
|
13
|
+
* `cookiePolicyVersion()`, which needs `node:crypto`. Those two facts collide
|
|
14
|
+
* under this package's build rules: `tsup.config.ts` runs with
|
|
15
|
+
* `treeshake: false` — REQUIRED to keep the `'use client'` prologue on the
|
|
16
|
+
* `./consent` entry from being stripped (see the R4 note there) — and with
|
|
17
|
+
* tree-shaking off, esbuild bundles an imported FILE WHOLESALE, not just the
|
|
18
|
+
* specific binding a module asked for. So if `cookiePolicyVersion` (and its
|
|
19
|
+
* `node:crypto` import) lived in the SAME file as `CONSENT_CATEGORIES`, every
|
|
20
|
+
* client component that only wanted the category list would drag `node:crypto`
|
|
21
|
+
* into the browser bundle too — dead weight at best, a broken build on a
|
|
22
|
+
* bundler stricter than tsup about Node builtins in client code at worst. This
|
|
23
|
+
* was caught live: `tests/consent-subpath-isolation.test.ts` asserts `./consent`
|
|
24
|
+
* never references `crypto`, and failed until this split existed.
|
|
25
|
+
*
|
|
26
|
+
* `./consent-server.ts` re-exports everything here, so
|
|
27
|
+
* `@reblu/site-next/consent-server` keeps serving the full public surface —
|
|
28
|
+
* this split is purely internal.
|
|
29
|
+
*
|
|
30
|
+
* A cookie is attacker- and drift-controlled input: a visitor can edit it in
|
|
31
|
+
* devtools, an older SDK version can leave a differently-shaped payload
|
|
32
|
+
* behind, and a proxy or extension can mangle it in transit.
|
|
33
|
+
* `parseConsentCookie` never coerces a doubtful value toward "granted" —
|
|
34
|
+
* anything that is not EXACTLY the expected shape (an object, every known
|
|
35
|
+
* category present and a strict boolean, `policyVersion` a string or `null`)
|
|
36
|
+
* resolves to `null`, which `./consent`'s provider treats identically to "no
|
|
37
|
+
* cookie yet": ask again, default every category denied. Silently granting a
|
|
38
|
+
* truthy-but-malformed value is the one mistake this module is built to make
|
|
39
|
+
* impossible.
|
|
40
|
+
*/
|
|
41
|
+
/** The first-party cookie a visitor's consent choice is stored in. */
|
|
42
|
+
declare const CONSENT_COOKIE_NAME = "reblu_consent";
|
|
43
|
+
/**
|
|
44
|
+
* The fixed, non-tenant-editable category taxonomy (REB-642 §7.1): "If each
|
|
45
|
+
* SITE invents its own categories, no two clinics can be audited the same
|
|
46
|
+
* way." `necessary` is deliberately NOT a member — it is not a choice, it has
|
|
47
|
+
* no toggle, and it is never read from or written to the cookie.
|
|
48
|
+
*/
|
|
49
|
+
declare const CONSENT_CATEGORIES: readonly ["analytics", "marketing", "preferences"];
|
|
50
|
+
/** One of the fixed consent categories. */
|
|
51
|
+
type ConsentCategory = (typeof CONSENT_CATEGORIES)[number];
|
|
52
|
+
/** A visitor's granted/denied state for every optional category. */
|
|
53
|
+
type ConsentCategoryState = Record<ConsentCategory, boolean>;
|
|
54
|
+
/** The full payload stored in the consent cookie. */
|
|
55
|
+
interface ConsentCookiePayload {
|
|
56
|
+
categories: ConsentCategoryState;
|
|
57
|
+
/** The `cookiePolicyVersion()` in force when this choice was recorded, or `null`. */
|
|
58
|
+
policyVersion: string | null;
|
|
59
|
+
}
|
|
60
|
+
/** Every optional category denied — the only safe default. */
|
|
61
|
+
declare const DEFAULT_CONSENT_CATEGORIES: Readonly<ConsentCategoryState>;
|
|
62
|
+
/**
|
|
63
|
+
* Parses a raw consent cookie value into a validated payload, or `null` if the
|
|
64
|
+
* value is absent or does not match the expected shape exactly. See the module
|
|
65
|
+
* doc for why this fails closed rather than coercing.
|
|
66
|
+
*/
|
|
67
|
+
declare function parseConsentCookie(raw: string | null | undefined): ConsentCookiePayload | null;
|
|
68
|
+
/** Serialises a consent payload for storage in the cookie. */
|
|
69
|
+
declare function serializeConsentCookie(payload: ConsentCookiePayload): string;
|
|
70
|
+
|
|
71
|
+
export { CONSENT_CATEGORIES as C, DEFAULT_CONSENT_CATEGORIES as D, CONSENT_COOKIE_NAME as a, type ConsentCategory as b, type ConsentCategoryState as c, type ConsentCookiePayload as d, parseConsentCookie as p, serializeConsentCookie as s };
|