@reblu/site-next 1.8.0 → 1.10.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/dist/academy.d.ts CHANGED
@@ -1,85 +1,6 @@
1
1
  import * as react_jsx_runtime from 'react/jsx-runtime';
2
-
3
- /**
4
- * `@reblu/site-next/academy` — validation-field derivation (REB-584 follow-up).
5
- *
6
- * PER-TENANT, DATA-DRIVEN (not code-driven): whether an enrollment request asks
7
- * for extra verification, and which fields it asks for, comes entirely from the
8
- * tenant's academy config / course data. This module never hardcodes a
9
- * sector-specific field name or label — the SDK does not know what a
10
- * "colegiado" is, and never will.
11
- *
12
- * Today the public course-detail contract (`AcademyCourseDetail`) exposes only
13
- * `requiresValidation` + `validationFieldLabel` — a single generic field, no
14
- * per-field `required` flag of its own (that flag lives on `AcademyConfig`,
15
- * server-side only, and isn't in the DTO yet). REB-83 is expected to add a
16
- * genuinely tenant-configurable list (`AcademyConfig.validationFields: [{ key,
17
- * label, required, maxLength? }]`) to that contract, which is how the upcoming
18
- * `collegiateNumber` / `professionalCollege` keys arrive — as DATA, not a code
19
- * change here.
20
- *
21
- * `AcademyValidationSource` is therefore defined STRUCTURALLY, not imported from
22
- * `@reblu/site-contracts`: it accepts whatever subset of fields the caller's
23
- * data actually has today, and remains satisfied once REB-83 lands a wider
24
- * real type — a course/config object with a `validationFields` array is already
25
- * assignable to this interface without a SITE-side type change.
26
- */
27
- /** A single tenant-defined (or derived) validation field the form should render. */
28
- interface AcademyValidationField {
29
- key: string;
30
- label: string;
31
- required: boolean;
32
- maxLength?: number;
33
- }
34
- /**
35
- * The shape a tenant-configured validation field list entry may take
36
- * (REB-83). `required` and `maxLength` are optional on the INPUT — the output
37
- * `AcademyValidationField` always carries a resolved `required`.
38
- */
39
- interface AcademyValidationFieldConfig {
40
- key: string;
41
- label: string;
42
- required?: boolean;
43
- maxLength?: number;
44
- }
45
- /**
46
- * Structural input — a subset of `AcademyCourseDetail` (today) plus the
47
- * optional REB-83 fields once the config/course exposes them. Any real DTO
48
- * that is at least this wide is assignable here without an import.
49
- */
50
- interface AcademyValidationSource {
51
- requiresValidation: boolean;
52
- /** Present on `AcademyCourseDetail` today. */
53
- validationFieldLabel?: string | null;
54
- /**
55
- * NOT yet in `AcademyCourseDetail` (server-only, on `AcademyConfig`) —
56
- * accepted here so a SITE that already has it (e.g. from a wider tenant
57
- * config read) can pass it through; defaults to `true` (erring strict) when
58
- * absent, matching the existing demo-site precedent.
59
- */
60
- validationFieldRequired?: boolean | null;
61
- /** REB-83 — tenant-defined list. When present and non-empty, takes priority
62
- * over the single-field fallback below. */
63
- validationFields?: AcademyValidationFieldConfig[] | null;
64
- }
65
- interface DeriveValidationFieldsOptions {
66
- /** @default 'validationField' */
67
- genericKey?: string;
68
- /** @default 'Dato de verificación' */
69
- genericLabel?: string;
70
- }
71
- /**
72
- * Derives the list of validation fields an enrollment form should render for
73
- * a course, from data alone.
74
- *
75
- * - `requiresValidation` is false → no fields (the form renders none).
76
- * - A non-empty `validationFields` list (REB-83) → used verbatim, arbitrary
77
- * keys included, each entry's `required` defaulting to `true` when absent.
78
- * - Otherwise → falls back to the single generic field the SITE already knows
79
- * today, labeled from `validationFieldLabel` (or the generic fallback
80
- * label when the tenant configured no label).
81
- */
82
- declare function deriveValidationFields(source: AcademyValidationSource, options?: DeriveValidationFieldsOptions): AcademyValidationField[];
2
+ import { AcademyValidationField } from './academy-server.js';
3
+ export { AcademyValidationFieldConfig, AcademyValidationSource, DeriveValidationFieldsOptions, deriveValidationFields } from './academy-server.js';
83
4
 
84
5
  /**
85
6
  * `AcademyEnrollmentForm` (REB-584 follow-up) — the SDK-published academy
@@ -145,4 +66,4 @@ interface AcademyEnrollmentFormProps {
145
66
  }
146
67
  declare function AcademyEnrollmentForm(props: AcademyEnrollmentFormProps): react_jsx_runtime.JSX.Element;
147
68
 
148
- export { AcademyEnrollmentForm, type AcademyEnrollmentFormCopy, type AcademyEnrollmentFormProps, type AcademyValidationField, type AcademyValidationFieldConfig, type AcademyValidationSource, type DeriveValidationFieldsOptions, deriveValidationFields };
69
+ export { AcademyEnrollmentForm, type AcademyEnrollmentFormCopy, type AcademyEnrollmentFormProps, AcademyValidationField };
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/academy/enrollment-form.tsx","../src/academy/derive-validation-fields.ts"],"sourcesContent":["'use client'\n\nimport * as React from 'react'\nimport { Button, Input, cn } from '@reblu/site-ui'\nimport { Label } from '@reblu/site-ui/label'\nimport type { AcademyValidationField } from './derive-validation-fields'\n\n/**\n * `AcademyEnrollmentForm` (REB-584 follow-up) — the SDK-published academy\n * enrollment-request form, extracted from demo-site's `EnrollmentCta` and\n * generalized to be PER-TENANT CUSTOMIZABLE and DATA-driven rather than\n * code-driven.\n *\n * Two things carried over from the original component are security/product\n * decisions, not styling ones — read `derive-validation-fields.ts`'s docblock\n * for the first, and this one for the second:\n *\n * The success/error copy this form shows is deliberately never \"your request\n * was created\" — the upstream route (`POST\n * /api/site/academy/enrollment-requests`) replies UNIFORMLY regardless of\n * whether the email was new (a request row was created) or already known (a\n * sign-in link was sent, no request row created), by anti-enumeration design.\n * `onSubmitted` fires on any 2xx, never implying the request was recorded.\n *\n * `validationFields` is fully DATA-driven (see `deriveValidationFields` in\n * this same subpath): when empty, this form renders no extra input at all and\n * submits with no `validationData` key — the negative control a\n * no-validation tenant needs. Arbitrary keys (REB-83's future\n * `collegiateNumber` / `professionalCollege`) work without a new release,\n * because this component never hardcodes a field name — it only ever\n * iterates the array it was given.\n */\n\nexport interface AcademyEnrollmentFormCopy {\n emailLabel: string\n emailPlaceholder: string\n /** Always visible — the request goes to human review, never an automatic purchase. */\n reviewNotice: string\n submitCta: string\n submittingCta: string\n successTitle: string\n /** Shown only when the server response carries no `message` of its own. */\n successFallbackMessage: string\n /** `{label}` is replaced with the field's own label (lowercased). */\n missingFieldError: string\n invalidEmailError: string\n rateLimitError: string\n networkError: string\n genericError: string\n}\n\nexport interface AcademyEnrollmentFormProps {\n /**\n * Where the form POSTs — a SITE-owned Route Handler (e.g. wired with\n * `createProxyRoute('/api/site/academy/enrollment-requests')` from\n * `@reblu/site-next/route-handlers`, or an equivalent bridge). The browser\n * can never call base-api directly: `X-Reblu-Api-Key` is a server secret.\n */\n enrollmentRequestUrl: string\n courseSlug: string\n /**\n * Data-driven validation fields for this course/tenant — build with\n * `deriveValidationFields` from the course/config data the SITE API\n * returned. Empty (the default) renders no extra field.\n */\n validationFields?: AcademyValidationField[]\n copy?: Partial<AcademyEnrollmentFormCopy>\n className?: string\n /** Fires on any 2xx response. Never treat this as confirmation a request was recorded — see module docblock. */\n onSubmitted?: (email: string) => void\n}\n\nconst DEFAULT_COPY: AcademyEnrollmentFormCopy = {\n emailLabel: 'Correo electrónico',\n emailPlaceholder: 'tu@email.com',\n reviewNotice:\n 'Tu solicitud será revisada por nuestro equipo antes de confirmarse. No se realiza ningún cobro automático: si es aprobada, recibirás un email con un enlace para completar el pago.',\n submitCta: 'Solicitar inscripción',\n submittingCta: 'Enviando...',\n successTitle: 'Solicitud enviada',\n successFallbackMessage: 'Si tu solicitud es válida, recibirás un email con los siguientes pasos.',\n missingFieldError: 'Introduce {label}.',\n invalidEmailError: 'Introduce un correo electrónico válido.',\n rateLimitError: 'Demasiadas solicitudes. Espera unos minutos antes de volver a intentarlo.',\n networkError: 'Error de conexión. Por favor, inténtalo de nuevo.',\n genericError: 'No hemos podido procesar la solicitud. Inténtalo de nuevo.',\n}\n\nconst EMAIL_REGEX = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\n\nfunction isValidEmail(value: string): boolean {\n return EMAIL_REGEX.test(value)\n}\n\ninterface ServerErrorBody {\n error?: string\n message?: string\n // Some SITE-owned bridges unwrap upstream into `{ data: { message } }` on\n // success and pass `error` through as a plain string on failure — this form\n // is written to work behind either shape (see module docblock).\n data?: { message?: string }\n details?: { fieldErrors?: Record<string, string[]>; formErrors?: string[] }\n}\n\nasync function parseJsonSafely(res: Response): Promise<ServerErrorBody> {\n try {\n return (await res.json()) as ServerErrorBody\n } catch {\n return {}\n }\n}\n\ntype FormState = 'idle' | 'submitting' | 'done' | 'error'\n\nexport function AcademyEnrollmentForm(props: AcademyEnrollmentFormProps) {\n const {\n enrollmentRequestUrl,\n courseSlug,\n validationFields = [],\n copy,\n className,\n onSubmitted,\n } = props\n const c = { ...DEFAULT_COPY, ...copy }\n\n const [email, setEmail] = React.useState('')\n const [values, setValues] = React.useState<Record<string, string>>({})\n const [state, setState] = React.useState<FormState>('idle')\n const [fieldErrors, setFieldErrors] = React.useState<Record<string, string>>({})\n const [generalError, setGeneralError] = React.useState<string | null>(null)\n const [successMessage, setSuccessMessage] = React.useState<string>('')\n\n const emailRef = React.useRef<HTMLInputElement>(null)\n const fieldRefs = React.useRef(new Map<string, HTMLInputElement>())\n const generalErrorRef = React.useRef<HTMLParagraphElement>(null)\n const successHeadingRef = React.useRef<HTMLHeadingElement>(null)\n\n // On success the form is replaced by the result panel, so the button that\n // was just pressed unmounts and focus would fall to <body> — dropping a\n // keyboard/screen-reader user at the top of the document. Move it instead.\n React.useEffect(() => {\n if (state === 'done') successHeadingRef.current?.focus()\n }, [state])\n\n // On an error, move focus to the FIRST thing actually wrong, in DOM order:\n // the email field, then the first validation field with an error, then the\n // form-level error paragraph. Ties the announcement to what it is about\n // (WCAG 3.3.1) instead of leaving a keyboard user to hunt for it.\n React.useEffect(() => {\n if (state !== 'error') return\n if (fieldErrors.email) {\n emailRef.current?.focus()\n return\n }\n const firstInvalidField = validationFields.find((field) => fieldErrors[field.key])\n if (firstInvalidField) {\n fieldRefs.current.get(firstInvalidField.key)?.focus()\n return\n }\n generalErrorRef.current?.focus()\n // `validationFields` is a per-render prop, not state this effect reacts\n // to — it only needs the CURRENT list at the moment `state`/`fieldErrors`\n // change, never a re-run when the (structurally stable, caller-owned)\n // array reference changes on its own.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [state, fieldErrors])\n\n function setFieldValue(key: string, value: string) {\n setValues((prev) => ({ ...prev, [key]: value }))\n }\n\n function validateClientSide(trimmedEmail: string): Record<string, string> {\n const errors: Record<string, string> = {}\n if (!isValidEmail(trimmedEmail)) {\n errors.email = c.invalidEmailError\n }\n for (const field of validationFields) {\n if (!field.required) continue\n const value = (values[field.key] ?? '').trim()\n if (!value) {\n errors[field.key] = c.missingFieldError.replace('{label}', field.label.toLowerCase())\n }\n }\n return errors\n }\n\n function applyServerFieldErrors(details: ServerErrorBody['details']): {\n matched: Record<string, string>\n leftover: string[]\n } {\n const matched: Record<string, string> = {}\n const leftover: string[] = []\n const known = new Set(['email', ...validationFields.map((f) => f.key)])\n\n for (const [key, messages] of Object.entries(details?.fieldErrors ?? {})) {\n const message = messages?.[0]\n if (!message) continue\n if (known.has(key)) {\n matched[key] = message\n } else {\n leftover.push(message)\n }\n }\n return { matched, leftover }\n }\n\n async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {\n event.preventDefault()\n if (state === 'submitting') return\n\n setGeneralError(null)\n setFieldErrors({})\n\n const trimmedEmail = email.trim().toLowerCase()\n const clientErrors = validateClientSide(trimmedEmail)\n if (Object.keys(clientErrors).length > 0) {\n setFieldErrors(clientErrors)\n setState('error')\n return\n }\n\n setState('submitting')\n\n let succeeded = false\n let messageToReport = ''\n try {\n const res = await fetch(enrollmentRequestUrl, {\n method: 'POST',\n credentials: 'same-origin',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n courseSlug,\n email: trimmedEmail,\n ...(validationFields.length > 0\n ? {\n validationData: Object.fromEntries(\n validationFields.map((field) => [field.key, (values[field.key] ?? '').trim()]),\n ),\n }\n : {}),\n }),\n })\n\n if (res.status === 429) {\n setGeneralError(c.rateLimitError)\n setState('error')\n return\n }\n\n const json = await parseJsonSafely(res)\n\n if (!res.ok) {\n const { matched, leftover } = applyServerFieldErrors(json.details)\n if (Object.keys(matched).length > 0) {\n setFieldErrors(matched)\n if (leftover.length > 0) setGeneralError(leftover.join(' '))\n setState('error')\n return\n }\n const fallback =\n json.message ||\n (typeof json.error === 'string' ? json.error : undefined) ||\n leftover.join(' ') ||\n c.genericError\n setGeneralError(fallback)\n setState('error')\n return\n }\n\n messageToReport = json.data?.message || json.message || c.successFallbackMessage\n succeeded = true\n } catch {\n setGeneralError(c.networkError)\n setState('error')\n return\n }\n\n if (succeeded) {\n setSuccessMessage(messageToReport)\n setState('done')\n try {\n onSubmitted?.(trimmedEmail)\n } catch (err) {\n console.error('[AcademyEnrollmentForm] onSubmitted callback threw:', err)\n }\n }\n }\n\n const reviewNotice = <p className='text-muted-foreground text-sm'>{c.reviewNotice}</p>\n\n if (state === 'done') {\n return (\n <div className={cn('space-y-4 text-center', className)}>\n {reviewNotice}\n <div className='space-y-1.5 rounded-xl border border-border p-5'>\n <h2 className='text-lg font-semibold' ref={successHeadingRef} tabIndex={-1}>\n {c.successTitle}\n </h2>\n <p className='text-muted-foreground text-sm'>{successMessage}</p>\n </div>\n </div>\n )\n }\n\n const submitting = state === 'submitting'\n\n return (\n <div className={cn('space-y-4', className)}>\n {reviewNotice}\n <form\n onSubmit={(e) => void handleSubmit(e)}\n className='space-y-4 rounded-xl border border-border p-5'\n noValidate\n >\n <div className='space-y-1.5'>\n <Label htmlFor='academy-enrollment-email'>{c.emailLabel}</Label>\n <Input\n id='academy-enrollment-email'\n ref={emailRef}\n type='email'\n autoComplete='email'\n placeholder={c.emailPlaceholder}\n value={email}\n onChange={(e) => setEmail(e.target.value)}\n disabled={submitting}\n aria-invalid={fieldErrors.email ? true : undefined}\n aria-describedby={fieldErrors.email ? 'academy-enrollment-email-error' : undefined}\n />\n {fieldErrors.email && (\n <p\n id='academy-enrollment-email-error'\n role='alert'\n className='text-destructive text-sm'\n >\n {fieldErrors.email}\n </p>\n )}\n </div>\n\n {validationFields.map((field) => {\n const inputId = `academy-enrollment-${field.key}`\n const errorId = `${inputId}-error`\n const fieldError = fieldErrors[field.key]\n return (\n <div className='space-y-1.5' key={field.key}>\n <Label htmlFor={inputId}>{field.label}</Label>\n <Input\n id={inputId}\n ref={(el) => {\n if (el) fieldRefs.current.set(field.key, el)\n else fieldRefs.current.delete(field.key)\n }}\n type='text'\n maxLength={field.maxLength}\n value={values[field.key] ?? ''}\n onChange={(e) => setFieldValue(field.key, e.target.value)}\n disabled={submitting}\n aria-invalid={fieldError ? true : undefined}\n aria-describedby={fieldError ? errorId : undefined}\n />\n {fieldError && (\n <p id={errorId} role='alert' className='text-destructive text-sm'>\n {fieldError}\n </p>\n )}\n </div>\n )\n })}\n\n {generalError && (\n <p\n ref={generalErrorRef}\n role='alert'\n tabIndex={-1}\n className='text-destructive text-sm'\n >\n {generalError}\n </p>\n )}\n\n <Button type='submit' className='w-full' disabled={submitting}>\n {submitting ? c.submittingCta : c.submitCta}\n </Button>\n </form>\n </div>\n )\n}\n","/**\n * `@reblu/site-next/academy` — validation-field derivation (REB-584 follow-up).\n *\n * PER-TENANT, DATA-DRIVEN (not code-driven): whether an enrollment request asks\n * for extra verification, and which fields it asks for, comes entirely from the\n * tenant's academy config / course data. This module never hardcodes a\n * sector-specific field name or label — the SDK does not know what a\n * \"colegiado\" is, and never will.\n *\n * Today the public course-detail contract (`AcademyCourseDetail`) exposes only\n * `requiresValidation` + `validationFieldLabel` — a single generic field, no\n * per-field `required` flag of its own (that flag lives on `AcademyConfig`,\n * server-side only, and isn't in the DTO yet). REB-83 is expected to add a\n * genuinely tenant-configurable list (`AcademyConfig.validationFields: [{ key,\n * label, required, maxLength? }]`) to that contract, which is how the upcoming\n * `collegiateNumber` / `professionalCollege` keys arrive — as DATA, not a code\n * change here.\n *\n * `AcademyValidationSource` is therefore defined STRUCTURALLY, not imported from\n * `@reblu/site-contracts`: it accepts whatever subset of fields the caller's\n * data actually has today, and remains satisfied once REB-83 lands a wider\n * real type — a course/config object with a `validationFields` array is already\n * assignable to this interface without a SITE-side type change.\n */\n\n/** A single tenant-defined (or derived) validation field the form should render. */\nexport interface AcademyValidationField {\n key: string\n label: string\n required: boolean\n maxLength?: number\n}\n\n/**\n * The shape a tenant-configured validation field list entry may take\n * (REB-83). `required` and `maxLength` are optional on the INPUT — the output\n * `AcademyValidationField` always carries a resolved `required`.\n */\nexport interface AcademyValidationFieldConfig {\n key: string\n label: string\n required?: boolean\n maxLength?: number\n}\n\n/**\n * Structural input — a subset of `AcademyCourseDetail` (today) plus the\n * optional REB-83 fields once the config/course exposes them. Any real DTO\n * that is at least this wide is assignable here without an import.\n */\nexport interface AcademyValidationSource {\n requiresValidation: boolean\n /** Present on `AcademyCourseDetail` today. */\n validationFieldLabel?: string | null\n /**\n * NOT yet in `AcademyCourseDetail` (server-only, on `AcademyConfig`) —\n * accepted here so a SITE that already has it (e.g. from a wider tenant\n * config read) can pass it through; defaults to `true` (erring strict) when\n * absent, matching the existing demo-site precedent.\n */\n validationFieldRequired?: boolean | null\n /** REB-83 — tenant-defined list. When present and non-empty, takes priority\n * over the single-field fallback below. */\n validationFields?: AcademyValidationFieldConfig[] | null\n}\n\nexport interface DeriveValidationFieldsOptions {\n /** @default 'validationField' */\n genericKey?: string\n /** @default 'Dato de verificación' */\n genericLabel?: string\n}\n\nexport const DEFAULT_GENERIC_VALIDATION_KEY = 'validationField'\nexport const DEFAULT_GENERIC_VALIDATION_LABEL = 'Dato de verificación'\n\n/**\n * Derives the list of validation fields an enrollment form should render for\n * a course, from data alone.\n *\n * - `requiresValidation` is false → no fields (the form renders none).\n * - A non-empty `validationFields` list (REB-83) → used verbatim, arbitrary\n * keys included, each entry's `required` defaulting to `true` when absent.\n * - Otherwise → falls back to the single generic field the SITE already knows\n * today, labeled from `validationFieldLabel` (or the generic fallback\n * label when the tenant configured no label).\n */\nexport function deriveValidationFields(\n source: AcademyValidationSource,\n options: DeriveValidationFieldsOptions = {},\n): AcademyValidationField[] {\n if (!source.requiresValidation) return []\n\n if (source.validationFields && source.validationFields.length > 0) {\n return source.validationFields.map((field) => ({\n key: field.key,\n label: field.label,\n required: field.required ?? true,\n ...(field.maxLength !== undefined ? { maxLength: field.maxLength } : {}),\n }))\n }\n\n return [\n {\n key: options.genericKey ?? DEFAULT_GENERIC_VALIDATION_KEY,\n label:\n source.validationFieldLabel || options.genericLabel || DEFAULT_GENERIC_VALIDATION_LABEL,\n required: source.validationFieldRequired ?? true,\n },\n ]\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/academy/enrollment-form.tsx","../src/academy/derive-validation-fields.ts"],"sourcesContent":["'use client'\n\nimport * as React from 'react'\nimport { Button, Input, cn } from '@reblu/site-ui'\nimport { Label } from '@reblu/site-ui/label'\nimport type { AcademyValidationField } from './derive-validation-fields'\n\n/**\n * `AcademyEnrollmentForm` (REB-584 follow-up) — the SDK-published academy\n * enrollment-request form, extracted from demo-site's `EnrollmentCta` and\n * generalized to be PER-TENANT CUSTOMIZABLE and DATA-driven rather than\n * code-driven.\n *\n * Two things carried over from the original component are security/product\n * decisions, not styling ones — read `derive-validation-fields.ts`'s docblock\n * for the first, and this one for the second:\n *\n * The success/error copy this form shows is deliberately never \"your request\n * was created\" — the upstream route (`POST\n * /api/site/academy/enrollment-requests`) replies UNIFORMLY regardless of\n * whether the email was new (a request row was created) or already known (a\n * sign-in link was sent, no request row created), by anti-enumeration design.\n * `onSubmitted` fires on any 2xx, never implying the request was recorded.\n *\n * `validationFields` is fully DATA-driven (see `deriveValidationFields` in\n * this same subpath): when empty, this form renders no extra input at all and\n * submits with no `validationData` key — the negative control a\n * no-validation tenant needs. Arbitrary keys (REB-83's future\n * `collegiateNumber` / `professionalCollege`) work without a new release,\n * because this component never hardcodes a field name — it only ever\n * iterates the array it was given.\n */\n\nexport interface AcademyEnrollmentFormCopy {\n emailLabel: string\n emailPlaceholder: string\n /** Always visible — the request goes to human review, never an automatic purchase. */\n reviewNotice: string\n submitCta: string\n submittingCta: string\n successTitle: string\n /** Shown only when the server response carries no `message` of its own. */\n successFallbackMessage: string\n /** `{label}` is replaced with the field's own label (lowercased). */\n missingFieldError: string\n invalidEmailError: string\n rateLimitError: string\n networkError: string\n genericError: string\n}\n\nexport interface AcademyEnrollmentFormProps {\n /**\n * Where the form POSTs — a SITE-owned Route Handler (e.g. wired with\n * `createProxyRoute('/api/site/academy/enrollment-requests')` from\n * `@reblu/site-next/route-handlers`, or an equivalent bridge). The browser\n * can never call base-api directly: `X-Reblu-Api-Key` is a server secret.\n */\n enrollmentRequestUrl: string\n courseSlug: string\n /**\n * Data-driven validation fields for this course/tenant — build with\n * `deriveValidationFields` from the course/config data the SITE API\n * returned. Empty (the default) renders no extra field.\n */\n validationFields?: AcademyValidationField[]\n copy?: Partial<AcademyEnrollmentFormCopy>\n className?: string\n /** Fires on any 2xx response. Never treat this as confirmation a request was recorded — see module docblock. */\n onSubmitted?: (email: string) => void\n}\n\nconst DEFAULT_COPY: AcademyEnrollmentFormCopy = {\n emailLabel: 'Correo electrónico',\n emailPlaceholder: 'tu@email.com',\n reviewNotice:\n 'Tu solicitud será revisada por nuestro equipo antes de confirmarse. No se realiza ningún cobro automático: si es aprobada, recibirás un email con un enlace para completar el pago.',\n submitCta: 'Solicitar inscripción',\n submittingCta: 'Enviando...',\n successTitle: 'Solicitud enviada',\n successFallbackMessage: 'Si tu solicitud es válida, recibirás un email con los siguientes pasos.',\n missingFieldError: 'Introduce {label}.',\n invalidEmailError: 'Introduce un correo electrónico válido.',\n rateLimitError: 'Demasiadas solicitudes. Espera unos minutos antes de volver a intentarlo.',\n networkError: 'Error de conexión. Por favor, inténtalo de nuevo.',\n genericError: 'No hemos podido procesar la solicitud. Inténtalo de nuevo.',\n}\n\nconst EMAIL_REGEX = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/\n\nfunction isValidEmail(value: string): boolean {\n return EMAIL_REGEX.test(value)\n}\n\ninterface ServerErrorBody {\n error?: string\n message?: string\n // Some SITE-owned bridges unwrap upstream into `{ data: { message } }` on\n // success and pass `error` through as a plain string on failure — this form\n // is written to work behind either shape (see module docblock).\n data?: { message?: string }\n details?: { fieldErrors?: Record<string, string[]>; formErrors?: string[] }\n}\n\nasync function parseJsonSafely(res: Response): Promise<ServerErrorBody> {\n try {\n return (await res.json()) as ServerErrorBody\n } catch {\n return {}\n }\n}\n\ntype FormState = 'idle' | 'submitting' | 'done' | 'error'\n\nexport function AcademyEnrollmentForm(props: AcademyEnrollmentFormProps) {\n const {\n enrollmentRequestUrl,\n courseSlug,\n validationFields = [],\n copy,\n className,\n onSubmitted,\n } = props\n const c = { ...DEFAULT_COPY, ...copy }\n\n const [email, setEmail] = React.useState('')\n const [values, setValues] = React.useState<Record<string, string>>({})\n const [state, setState] = React.useState<FormState>('idle')\n const [fieldErrors, setFieldErrors] = React.useState<Record<string, string>>({})\n const [generalError, setGeneralError] = React.useState<string | null>(null)\n const [successMessage, setSuccessMessage] = React.useState<string>('')\n\n const emailRef = React.useRef<HTMLInputElement>(null)\n const fieldRefs = React.useRef(new Map<string, HTMLInputElement>())\n const generalErrorRef = React.useRef<HTMLParagraphElement>(null)\n const successHeadingRef = React.useRef<HTMLHeadingElement>(null)\n\n // On success the form is replaced by the result panel, so the button that\n // was just pressed unmounts and focus would fall to <body> — dropping a\n // keyboard/screen-reader user at the top of the document. Move it instead.\n React.useEffect(() => {\n if (state === 'done') successHeadingRef.current?.focus()\n }, [state])\n\n // On an error, move focus to the FIRST thing actually wrong, in DOM order:\n // the email field, then the first validation field with an error, then the\n // form-level error paragraph. Ties the announcement to what it is about\n // (WCAG 3.3.1) instead of leaving a keyboard user to hunt for it.\n React.useEffect(() => {\n if (state !== 'error') return\n if (fieldErrors.email) {\n emailRef.current?.focus()\n return\n }\n const firstInvalidField = validationFields.find((field) => fieldErrors[field.key])\n if (firstInvalidField) {\n fieldRefs.current.get(firstInvalidField.key)?.focus()\n return\n }\n generalErrorRef.current?.focus()\n // `validationFields` is a per-render prop, not state this effect reacts\n // to — it only needs the CURRENT list at the moment `state`/`fieldErrors`\n // change, never a re-run when the (structurally stable, caller-owned)\n // array reference changes on its own.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [state, fieldErrors])\n\n function setFieldValue(key: string, value: string) {\n setValues((prev) => ({ ...prev, [key]: value }))\n }\n\n function validateClientSide(trimmedEmail: string): Record<string, string> {\n const errors: Record<string, string> = {}\n if (!isValidEmail(trimmedEmail)) {\n errors.email = c.invalidEmailError\n }\n for (const field of validationFields) {\n if (!field.required) continue\n const value = (values[field.key] ?? '').trim()\n if (!value) {\n errors[field.key] = c.missingFieldError.replace('{label}', field.label.toLowerCase())\n }\n }\n return errors\n }\n\n function applyServerFieldErrors(details: ServerErrorBody['details']): {\n matched: Record<string, string>\n leftover: string[]\n } {\n const matched: Record<string, string> = {}\n const leftover: string[] = []\n const known = new Set(['email', ...validationFields.map((f) => f.key)])\n\n for (const [key, messages] of Object.entries(details?.fieldErrors ?? {})) {\n const message = messages?.[0]\n if (!message) continue\n if (known.has(key)) {\n matched[key] = message\n } else {\n leftover.push(message)\n }\n }\n return { matched, leftover }\n }\n\n async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {\n event.preventDefault()\n if (state === 'submitting') return\n\n setGeneralError(null)\n setFieldErrors({})\n\n const trimmedEmail = email.trim().toLowerCase()\n const clientErrors = validateClientSide(trimmedEmail)\n if (Object.keys(clientErrors).length > 0) {\n setFieldErrors(clientErrors)\n setState('error')\n return\n }\n\n setState('submitting')\n\n let succeeded = false\n let messageToReport = ''\n try {\n const res = await fetch(enrollmentRequestUrl, {\n method: 'POST',\n credentials: 'same-origin',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify({\n courseSlug,\n email: trimmedEmail,\n ...(validationFields.length > 0\n ? {\n validationData: Object.fromEntries(\n validationFields.map((field) => [field.key, (values[field.key] ?? '').trim()]),\n ),\n }\n : {}),\n }),\n })\n\n if (res.status === 429) {\n setGeneralError(c.rateLimitError)\n setState('error')\n return\n }\n\n const json = await parseJsonSafely(res)\n\n if (!res.ok) {\n const { matched, leftover } = applyServerFieldErrors(json.details)\n if (Object.keys(matched).length > 0) {\n setFieldErrors(matched)\n if (leftover.length > 0) setGeneralError(leftover.join(' '))\n setState('error')\n return\n }\n const fallback =\n json.message ||\n (typeof json.error === 'string' ? json.error : undefined) ||\n leftover.join(' ') ||\n c.genericError\n setGeneralError(fallback)\n setState('error')\n return\n }\n\n messageToReport = json.data?.message || json.message || c.successFallbackMessage\n succeeded = true\n } catch {\n setGeneralError(c.networkError)\n setState('error')\n return\n }\n\n if (succeeded) {\n setSuccessMessage(messageToReport)\n setState('done')\n try {\n onSubmitted?.(trimmedEmail)\n } catch (err) {\n console.error('[AcademyEnrollmentForm] onSubmitted callback threw:', err)\n }\n }\n }\n\n const reviewNotice = <p className='text-muted-foreground text-sm'>{c.reviewNotice}</p>\n\n if (state === 'done') {\n return (\n <div className={cn('space-y-4 text-center', className)}>\n {reviewNotice}\n <div className='space-y-1.5 rounded-xl border border-border p-5'>\n <h2 className='text-lg font-semibold' ref={successHeadingRef} tabIndex={-1}>\n {c.successTitle}\n </h2>\n <p className='text-muted-foreground text-sm'>{successMessage}</p>\n </div>\n </div>\n )\n }\n\n const submitting = state === 'submitting'\n\n return (\n <div className={cn('space-y-4', className)}>\n {reviewNotice}\n <form\n onSubmit={(e) => void handleSubmit(e)}\n className='space-y-4 rounded-xl border border-border p-5'\n noValidate\n >\n <div className='space-y-1.5'>\n <Label htmlFor='academy-enrollment-email'>{c.emailLabel}</Label>\n <Input\n id='academy-enrollment-email'\n ref={emailRef}\n type='email'\n autoComplete='email'\n placeholder={c.emailPlaceholder}\n value={email}\n onChange={(e) => setEmail(e.target.value)}\n disabled={submitting}\n aria-invalid={fieldErrors.email ? true : undefined}\n aria-describedby={fieldErrors.email ? 'academy-enrollment-email-error' : undefined}\n />\n {fieldErrors.email && (\n <p\n id='academy-enrollment-email-error'\n role='alert'\n className='text-destructive text-sm'\n >\n {fieldErrors.email}\n </p>\n )}\n </div>\n\n {validationFields.map((field) => {\n const inputId = `academy-enrollment-${field.key}`\n const errorId = `${inputId}-error`\n const fieldError = fieldErrors[field.key]\n return (\n <div className='space-y-1.5' key={field.key}>\n <Label htmlFor={inputId}>{field.label}</Label>\n <Input\n id={inputId}\n ref={(el) => {\n if (el) fieldRefs.current.set(field.key, el)\n else fieldRefs.current.delete(field.key)\n }}\n type='text'\n maxLength={field.maxLength}\n value={values[field.key] ?? ''}\n onChange={(e) => setFieldValue(field.key, e.target.value)}\n disabled={submitting}\n aria-invalid={fieldError ? true : undefined}\n aria-describedby={fieldError ? errorId : undefined}\n />\n {fieldError && (\n <p id={errorId} role='alert' className='text-destructive text-sm'>\n {fieldError}\n </p>\n )}\n </div>\n )\n })}\n\n {generalError && (\n <p\n ref={generalErrorRef}\n role='alert'\n tabIndex={-1}\n className='text-destructive text-sm'\n >\n {generalError}\n </p>\n )}\n\n <Button type='submit' className='w-full' disabled={submitting}>\n {submitting ? c.submittingCta : c.submitCta}\n </Button>\n </form>\n </div>\n )\n}\n","/**\n * `@reblu/site-next/academy` (also re-exported from the server-safe\n * `@reblu/site-next/academy-server` subpath — see `./server.ts`) — validation-\n * field derivation (REB-584 follow-up).\n *\n * PER-TENANT, DATA-DRIVEN (not code-driven): whether an enrollment request asks\n * for extra verification, and which fields it asks for, comes entirely from the\n * tenant's academy config / course data. This module never hardcodes a\n * sector-specific field name or label — the SDK does not know what a\n * \"colegiado\" is, and never will.\n *\n * Today the public course-detail contract (`AcademyCourseDetail`) exposes only\n * `requiresValidation` + `validationFieldLabel` — a single generic field, no\n * per-field `required` flag of its own (that flag lives on `AcademyConfig`,\n * server-side only, and isn't in the DTO yet). REB-83 is expected to add a\n * genuinely tenant-configurable list (`AcademyConfig.validationFields: [{ key,\n * label, required, maxLength? }]`) to that contract, which is how the upcoming\n * `collegiateNumber` / `professionalCollege` keys arrive — as DATA, not a code\n * change here.\n *\n * `AcademyValidationSource` is therefore defined STRUCTURALLY, not imported from\n * `@reblu/site-contracts`: it accepts whatever subset of fields the caller's\n * data actually has today, and remains satisfied once REB-83 lands a wider\n * real type — a course/config object with a `validationFields` array is already\n * assignable to this interface without a SITE-side type change.\n */\n\n/** A single tenant-defined (or derived) validation field the form should render. */\nexport interface AcademyValidationField {\n key: string\n label: string\n required: boolean\n maxLength?: number\n}\n\n/**\n * The shape a tenant-configured validation field list entry may take\n * (REB-83). `required` and `maxLength` are optional on the INPUT — the output\n * `AcademyValidationField` always carries a resolved `required`.\n */\nexport interface AcademyValidationFieldConfig {\n key: string\n label: string\n required?: boolean\n maxLength?: number\n}\n\n/**\n * Structural input — a subset of `AcademyCourseDetail` (today) plus the\n * optional REB-83 fields once the config/course exposes them. Any real DTO\n * that is at least this wide is assignable here without an import.\n */\nexport interface AcademyValidationSource {\n requiresValidation: boolean\n /** Present on `AcademyCourseDetail` today. */\n validationFieldLabel?: string | null\n /**\n * NOT yet in `AcademyCourseDetail` (server-only, on `AcademyConfig`) —\n * accepted here so a SITE that already has it (e.g. from a wider tenant\n * config read) can pass it through; defaults to `true` (erring strict) when\n * absent, matching the existing demo-site precedent.\n */\n validationFieldRequired?: boolean | null\n /** REB-83 — tenant-defined list. When present and non-empty, takes priority\n * over the single-field fallback below. */\n validationFields?: AcademyValidationFieldConfig[] | null\n}\n\nexport interface DeriveValidationFieldsOptions {\n /** @default 'validationField' */\n genericKey?: string\n /** @default 'Dato de verificación' */\n genericLabel?: string\n}\n\nexport const DEFAULT_GENERIC_VALIDATION_KEY = 'validationField'\nexport const DEFAULT_GENERIC_VALIDATION_LABEL = 'Dato de verificación'\n\n/**\n * Derives the list of validation fields an enrollment form should render for\n * a course, from data alone.\n *\n * - `requiresValidation` is false → no fields (the form renders none).\n * - A non-empty `validationFields` list (REB-83) → used verbatim, arbitrary\n * keys included, each entry's `required` defaulting to `true` when absent.\n * - Otherwise → falls back to the single generic field the SITE already knows\n * today, labeled from `validationFieldLabel` (or the generic fallback\n * label when the tenant configured no label).\n */\nexport function deriveValidationFields(\n source: AcademyValidationSource,\n options: DeriveValidationFieldsOptions = {},\n): AcademyValidationField[] {\n if (!source.requiresValidation) return []\n\n if (source.validationFields && source.validationFields.length > 0) {\n return source.validationFields.map((field) => ({\n key: field.key,\n label: field.label,\n required: field.required ?? true,\n ...(field.maxLength !== undefined ? { maxLength: field.maxLength } : {}),\n }))\n }\n\n return [\n {\n key: options.genericKey ?? DEFAULT_GENERIC_VALIDATION_KEY,\n label:\n source.validationFieldLabel || options.genericLabel || DEFAULT_GENERIC_VALIDATION_LABEL,\n required: source.validationFieldRequired ?? true,\n },\n ]\n}\n"],"mappings":";;;AAEA,YAAY,WAAW;AACvB,SAAS,QAAQ,OAAO,UAAU;AAClC,SAAS,aAAa;AA4RC,cAMf,YANe;AAxNvB,IAAM,eAA0C;AAAA,EAC9C,YAAY;AAAA,EACZ,kBAAkB;AAAA,EAClB,cACE;AAAA,EACF,WAAW;AAAA,EACX,eAAe;AAAA,EACf,cAAc;AAAA,EACd,wBAAwB;AAAA,EACxB,mBAAmB;AAAA,EACnB,mBAAmB;AAAA,EACnB,gBAAgB;AAAA,EAChB,cAAc;AAAA,EACd,cAAc;AAChB;AAEA,IAAM,cAAc;AAEpB,SAAS,aAAa,OAAwB;AAC5C,SAAO,YAAY,KAAK,KAAK;AAC/B;AAYA,eAAe,gBAAgB,KAAyC;AACtE,MAAI;AACF,WAAQ,MAAM,IAAI,KAAK;AAAA,EACzB,QAAQ;AACN,WAAO,CAAC;AAAA,EACV;AACF;AAIO,SAAS,sBAAsB,OAAmC;AACvE,QAAM;AAAA,IACJ;AAAA,IACA;AAAA,IACA,mBAAmB,CAAC;AAAA,IACpB;AAAA,IACA;AAAA,IACA;AAAA,EACF,IAAI;AACJ,QAAM,IAAI,EAAE,GAAG,cAAc,GAAG,KAAK;AAErC,QAAM,CAAC,OAAO,QAAQ,IAAU,eAAS,EAAE;AAC3C,QAAM,CAAC,QAAQ,SAAS,IAAU,eAAiC,CAAC,CAAC;AACrE,QAAM,CAAC,OAAO,QAAQ,IAAU,eAAoB,MAAM;AAC1D,QAAM,CAAC,aAAa,cAAc,IAAU,eAAiC,CAAC,CAAC;AAC/E,QAAM,CAAC,cAAc,eAAe,IAAU,eAAwB,IAAI;AAC1E,QAAM,CAAC,gBAAgB,iBAAiB,IAAU,eAAiB,EAAE;AAErE,QAAM,WAAiB,aAAyB,IAAI;AACpD,QAAM,YAAkB,aAAO,oBAAI,IAA8B,CAAC;AAClE,QAAM,kBAAwB,aAA6B,IAAI;AAC/D,QAAM,oBAA0B,aAA2B,IAAI;AAK/D,EAAM,gBAAU,MAAM;AACpB,QAAI,UAAU,OAAQ,mBAAkB,SAAS,MAAM;AAAA,EACzD,GAAG,CAAC,KAAK,CAAC;AAMV,EAAM,gBAAU,MAAM;AACpB,QAAI,UAAU,QAAS;AACvB,QAAI,YAAY,OAAO;AACrB,eAAS,SAAS,MAAM;AACxB;AAAA,IACF;AACA,UAAM,oBAAoB,iBAAiB,KAAK,CAAC,UAAU,YAAY,MAAM,GAAG,CAAC;AACjF,QAAI,mBAAmB;AACrB,gBAAU,QAAQ,IAAI,kBAAkB,GAAG,GAAG,MAAM;AACpD;AAAA,IACF;AACA,oBAAgB,SAAS,MAAM;AAAA,EAMjC,GAAG,CAAC,OAAO,WAAW,CAAC;AAEvB,WAAS,cAAc,KAAa,OAAe;AACjD,cAAU,CAAC,UAAU,EAAE,GAAG,MAAM,CAAC,GAAG,GAAG,MAAM,EAAE;AAAA,EACjD;AAEA,WAAS,mBAAmB,cAA8C;AACxE,UAAM,SAAiC,CAAC;AACxC,QAAI,CAAC,aAAa,YAAY,GAAG;AAC/B,aAAO,QAAQ,EAAE;AAAA,IACnB;AACA,eAAW,SAAS,kBAAkB;AACpC,UAAI,CAAC,MAAM,SAAU;AACrB,YAAM,SAAS,OAAO,MAAM,GAAG,KAAK,IAAI,KAAK;AAC7C,UAAI,CAAC,OAAO;AACV,eAAO,MAAM,GAAG,IAAI,EAAE,kBAAkB,QAAQ,WAAW,MAAM,MAAM,YAAY,CAAC;AAAA,MACtF;AAAA,IACF;AACA,WAAO;AAAA,EACT;AAEA,WAAS,uBAAuB,SAG9B;AACA,UAAM,UAAkC,CAAC;AACzC,UAAM,WAAqB,CAAC;AAC5B,UAAM,QAAQ,oBAAI,IAAI,CAAC,SAAS,GAAG,iBAAiB,IAAI,CAAC,MAAM,EAAE,GAAG,CAAC,CAAC;AAEtE,eAAW,CAAC,KAAK,QAAQ,KAAK,OAAO,QAAQ,SAAS,eAAe,CAAC,CAAC,GAAG;AACxE,YAAM,UAAU,WAAW,CAAC;AAC5B,UAAI,CAAC,QAAS;AACd,UAAI,MAAM,IAAI,GAAG,GAAG;AAClB,gBAAQ,GAAG,IAAI;AAAA,MACjB,OAAO;AACL,iBAAS,KAAK,OAAO;AAAA,MACvB;AAAA,IACF;AACA,WAAO,EAAE,SAAS,SAAS;AAAA,EAC7B;AAEA,iBAAe,aAAa,OAAyC;AACnE,UAAM,eAAe;AACrB,QAAI,UAAU,aAAc;AAE5B,oBAAgB,IAAI;AACpB,mBAAe,CAAC,CAAC;AAEjB,UAAM,eAAe,MAAM,KAAK,EAAE,YAAY;AAC9C,UAAM,eAAe,mBAAmB,YAAY;AACpD,QAAI,OAAO,KAAK,YAAY,EAAE,SAAS,GAAG;AACxC,qBAAe,YAAY;AAC3B,eAAS,OAAO;AAChB;AAAA,IACF;AAEA,aAAS,YAAY;AAErB,QAAI,YAAY;AAChB,QAAI,kBAAkB;AACtB,QAAI;AACF,YAAM,MAAM,MAAM,MAAM,sBAAsB;AAAA,QAC5C,QAAQ;AAAA,QACR,aAAa;AAAA,QACb,SAAS,EAAE,gBAAgB,mBAAmB;AAAA,QAC9C,MAAM,KAAK,UAAU;AAAA,UACnB;AAAA,UACA,OAAO;AAAA,UACP,GAAI,iBAAiB,SAAS,IAC1B;AAAA,YACE,gBAAgB,OAAO;AAAA,cACrB,iBAAiB,IAAI,CAAC,UAAU,CAAC,MAAM,MAAM,OAAO,MAAM,GAAG,KAAK,IAAI,KAAK,CAAC,CAAC;AAAA,YAC/E;AAAA,UACF,IACA,CAAC;AAAA,QACP,CAAC;AAAA,MACH,CAAC;AAED,UAAI,IAAI,WAAW,KAAK;AACtB,wBAAgB,EAAE,cAAc;AAChC,iBAAS,OAAO;AAChB;AAAA,MACF;AAEA,YAAM,OAAO,MAAM,gBAAgB,GAAG;AAEtC,UAAI,CAAC,IAAI,IAAI;AACX,cAAM,EAAE,SAAS,SAAS,IAAI,uBAAuB,KAAK,OAAO;AACjE,YAAI,OAAO,KAAK,OAAO,EAAE,SAAS,GAAG;AACnC,yBAAe,OAAO;AACtB,cAAI,SAAS,SAAS,EAAG,iBAAgB,SAAS,KAAK,GAAG,CAAC;AAC3D,mBAAS,OAAO;AAChB;AAAA,QACF;AACA,cAAM,WACJ,KAAK,YACJ,OAAO,KAAK,UAAU,WAAW,KAAK,QAAQ,WAC/C,SAAS,KAAK,GAAG,KACjB,EAAE;AACJ,wBAAgB,QAAQ;AACxB,iBAAS,OAAO;AAChB;AAAA,MACF;AAEA,wBAAkB,KAAK,MAAM,WAAW,KAAK,WAAW,EAAE;AAC1D,kBAAY;AAAA,IACd,QAAQ;AACN,sBAAgB,EAAE,YAAY;AAC9B,eAAS,OAAO;AAChB;AAAA,IACF;AAEA,QAAI,WAAW;AACb,wBAAkB,eAAe;AACjC,eAAS,MAAM;AACf,UAAI;AACF,sBAAc,YAAY;AAAA,MAC5B,SAAS,KAAK;AACZ,gBAAQ,MAAM,uDAAuD,GAAG;AAAA,MAC1E;AAAA,IACF;AAAA,EACF;AAEA,QAAM,eAAe,oBAAC,OAAE,WAAU,iCAAiC,YAAE,cAAa;AAElF,MAAI,UAAU,QAAQ;AACpB,WACE,qBAAC,SAAI,WAAW,GAAG,yBAAyB,SAAS,GAClD;AAAA;AAAA,MACD,qBAAC,SAAI,WAAU,mDACb;AAAA,4BAAC,QAAG,WAAU,yBAAwB,KAAK,mBAAmB,UAAU,IACrE,YAAE,cACL;AAAA,QACA,oBAAC,OAAE,WAAU,iCAAiC,0BAAe;AAAA,SAC/D;AAAA,OACF;AAAA,EAEJ;AAEA,QAAM,aAAa,UAAU;AAE7B,SACE,qBAAC,SAAI,WAAW,GAAG,aAAa,SAAS,GACtC;AAAA;AAAA,IACD;AAAA,MAAC;AAAA;AAAA,QACC,UAAU,CAAC,MAAM,KAAK,aAAa,CAAC;AAAA,QACpC,WAAU;AAAA,QACV,YAAU;AAAA,QAEV;AAAA,+BAAC,SAAI,WAAU,eACb;AAAA,gCAAC,SAAM,SAAQ,4BAA4B,YAAE,YAAW;AAAA,YACxD;AAAA,cAAC;AAAA;AAAA,gBACC,IAAG;AAAA,gBACH,KAAK;AAAA,gBACL,MAAK;AAAA,gBACL,cAAa;AAAA,gBACb,aAAa,EAAE;AAAA,gBACf,OAAO;AAAA,gBACP,UAAU,CAAC,MAAM,SAAS,EAAE,OAAO,KAAK;AAAA,gBACxC,UAAU;AAAA,gBACV,gBAAc,YAAY,QAAQ,OAAO;AAAA,gBACzC,oBAAkB,YAAY,QAAQ,mCAAmC;AAAA;AAAA,YAC3E;AAAA,YACC,YAAY,SACX;AAAA,cAAC;AAAA;AAAA,gBACC,IAAG;AAAA,gBACH,MAAK;AAAA,gBACL,WAAU;AAAA,gBAET,sBAAY;AAAA;AAAA,YACf;AAAA,aAEJ;AAAA,UAEC,iBAAiB,IAAI,CAAC,UAAU;AAC/B,kBAAM,UAAU,sBAAsB,MAAM,GAAG;AAC/C,kBAAM,UAAU,GAAG,OAAO;AAC1B,kBAAM,aAAa,YAAY,MAAM,GAAG;AACxC,mBACE,qBAAC,SAAI,WAAU,eACb;AAAA,kCAAC,SAAM,SAAS,SAAU,gBAAM,OAAM;AAAA,cACtC;AAAA,gBAAC;AAAA;AAAA,kBACC,IAAI;AAAA,kBACJ,KAAK,CAAC,OAAO;AACX,wBAAI,GAAI,WAAU,QAAQ,IAAI,MAAM,KAAK,EAAE;AAAA,wBACtC,WAAU,QAAQ,OAAO,MAAM,GAAG;AAAA,kBACzC;AAAA,kBACA,MAAK;AAAA,kBACL,WAAW,MAAM;AAAA,kBACjB,OAAO,OAAO,MAAM,GAAG,KAAK;AAAA,kBAC5B,UAAU,CAAC,MAAM,cAAc,MAAM,KAAK,EAAE,OAAO,KAAK;AAAA,kBACxD,UAAU;AAAA,kBACV,gBAAc,aAAa,OAAO;AAAA,kBAClC,oBAAkB,aAAa,UAAU;AAAA;AAAA,cAC3C;AAAA,cACC,cACC,oBAAC,OAAE,IAAI,SAAS,MAAK,SAAQ,WAAU,4BACpC,sBACH;AAAA,iBAnB8B,MAAM,GAqBxC;AAAA,UAEJ,CAAC;AAAA,UAEA,gBACC;AAAA,YAAC;AAAA;AAAA,cACC,KAAK;AAAA,cACL,MAAK;AAAA,cACL,UAAU;AAAA,cACV,WAAU;AAAA,cAET;AAAA;AAAA,UACH;AAAA,UAGF,oBAAC,UAAO,MAAK,UAAS,WAAU,UAAS,UAAU,YAChD,uBAAa,EAAE,gBAAgB,EAAE,WACpC;AAAA;AAAA;AAAA,IACF;AAAA,KACF;AAEJ;;;ACvTO,IAAM,iCAAiC;AACvC,IAAM,mCAAmC;AAazC,SAAS,uBACd,QACA,UAAyC,CAAC,GAChB;AAC1B,MAAI,CAAC,OAAO,mBAAoB,QAAO,CAAC;AAExC,MAAI,OAAO,oBAAoB,OAAO,iBAAiB,SAAS,GAAG;AACjE,WAAO,OAAO,iBAAiB,IAAI,CAAC,WAAW;AAAA,MAC7C,KAAK,MAAM;AAAA,MACX,OAAO,MAAM;AAAA,MACb,UAAU,MAAM,YAAY;AAAA,MAC5B,GAAI,MAAM,cAAc,SAAY,EAAE,WAAW,MAAM,UAAU,IAAI,CAAC;AAAA,IACxE,EAAE;AAAA,EACJ;AAEA,SAAO;AAAA,IACL;AAAA,MACE,KAAK,QAAQ,cAAc;AAAA,MAC3B,OACE,OAAO,wBAAwB,QAAQ,gBAAgB;AAAA,MACzD,UAAU,OAAO,2BAA2B;AAAA,IAC9C;AAAA,EACF;AACF;","names":[]}
@@ -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 };