@reblu/site-next 1.7.0 → 1.8.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 ADDED
@@ -0,0 +1,473 @@
1
+ # @reblu/site-next
2
+
3
+ ## 1.8.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 984f8b5: Add `@reblu/site-next/academy`, exporting `AcademyEnrollmentForm` — a
8
+ copy-injectable, per-tenant customizable enrollment-request form — and
9
+ `deriveValidationFields`, a pure helper that derives its validation fields
10
+ from the course/config data a SITE's academy API already returns.
11
+
12
+ ## 1.7.1
13
+
14
+ ### Patch Changes
15
+
16
+ - 9f54c58: REB-774 — ship `CHANGELOG.md` inside the published npm tarball for every
17
+ `@reblu/site-*` package (verified via `pnpm pack`: it was previously absent,
18
+ since `"files": ["dist"]` limited the tarball to `dist/` and npm only
19
+ auto-includes `README.md`, `LICENSE` and `package.json`). Consumers can now
20
+ read the changelog for the version they installed straight from
21
+ `node_modules/@reblu/<pkg>/CHANGELOG.md`.
22
+ - Updated dependencies [9f54c58]
23
+ - Updated dependencies [9f54c58]
24
+ - @reblu/site-contracts@1.0.1
25
+ - @reblu/site-client@5.0.1
26
+ - @reblu/site-ui@0.5.1
27
+
28
+ ## 1.7.0
29
+
30
+ ### Minor Changes
31
+
32
+ - deda0c0: REB-762 / REB-763 — a user who asks for a new magic link is told the truth, once
33
+
34
+ Expired link → "Enlace caducado" → re-request from the form on that same page →
35
+ "enlace enviado" appears while "Enlace caducado" is still on screen, unchanged.
36
+ The expired notice is the more alarming of the two, so it is the one believed,
37
+ so the user requests again — within Supabase's 60-second per-user interval,
38
+ which drops the second link before any email is sent while our route answers a
39
+ cheerful 200. Nothing arrives, the screen still says expired, and round it goes.
40
+
41
+ Two changes close the loop:
42
+ - `AuthCallback` drops its verdict about the old link when a new one is
43
+ requested. `LoginForm` dispatches `reblu:magic-link-requested` on `window` and
44
+ the callback listens. This covers the whole class, not the reported case: an
45
+ expired link, an invalid one, an already-used one, and a failed exchange all
46
+ clear. The stale notice is REMOVED rather than dimmed — a greyed-out alarm is
47
+ still read as current.
48
+ - `LoginForm` holds its success panel for a 60-second cooldown with a visible
49
+ countdown, then offers "Pedir otro enlace". There is no submit button to press
50
+ during the window in which Supabase would silently drop the request. It leaks
51
+ nothing: the UI behaves identically for a real and a non-existent address.
52
+
53
+ Copy is now true in both branches. `magicLinkSuccessTitle` is "Revisa tu correo"
54
+ (was "¡Enlace enviado!") and `magicLinkSuccessMessage` no longer asserts a send
55
+ that may never have happened. base-api's `/api/site/auth/magic-link-request`
56
+ matches, in every branch including the deliberate silent 200 on our own IP
57
+ limiter — that silence stays, it is what stops a 429 from being an
58
+ account-existence oracle; only the lie is gone.
59
+
60
+ New public surface, all additive:
61
+ - `LoginForm` gains an optional `cooldownSeconds` prop and two copy keys,
62
+ `magicLinkCooldownMessage` (with a `{seconds}` placeholder) and
63
+ `magicLinkResendCta`. No existing prop changed shape. `AuthCallback`'s props
64
+ are untouched, so an already-published SITE page picks the fix up by
65
+ upgrading the package alone.
66
+ - `MAGIC_LINK_COOLDOWN_SECONDS`, `MAGIC_LINK_REQUESTED_EVENT`,
67
+ `emitMagicLinkRequested`, `onMagicLinkRequested`.
68
+
69
+ **Operators:** 60 is a MIRROR of the Supabase project's "minimum interval per
70
+ user", which we neither own nor can read at runtime — it is measured at 60 s on
71
+ the Dermamedicin project and set to 1 s on the local tenant stack, so the two
72
+ already differ by environment. It is declared once, in
73
+ `MAGIC_LINK_COOLDOWN_SECONDS`. If a project's interval changes, pass
74
+ `cooldownSeconds` for that SITE rather than adding a second literal.
75
+
76
+ ### Patch Changes
77
+
78
+ - Updated dependencies [340db30]
79
+ - @reblu/site-contracts@1.0.0
80
+ - @reblu/site-client@5.0.0
81
+
82
+ ## 1.6.0
83
+
84
+ ### Minor Changes
85
+
86
+ - 0e0a684: Add `@reblu/site-next/recaptcha` (REB-588) — the SITE's half of the contact-form captcha
87
+ contract, so no SITE re-implements Google's flow or verifies a token wrongly.
88
+
89
+ ## Why this exists
90
+
91
+ There were four copies of this flow. `apps/base-api` had one, `apps/demo-site` had another,
92
+ and the external `reblu-site-dermamedicin` repo had a third plus its own availability gate.
93
+ They had already drifted: one called `executeRecaptcha('submit_form')`, another
94
+ `'contact_form'`; one carried a retry loop and the others did not; one mounted the provider
95
+ eagerly, one on scroll, one on interaction. reCAPTCHA v3 scores **per action**, so two SITEs
96
+ naming the action differently are building two unrelated score histories for the same
97
+ feature — and a defect fixed in one copy stays live in the other three.
98
+
99
+ An external SITE repo could not share any of it. It can install `@reblu/site-*`; it cannot
100
+ import `apps/base-api/src/components/`, and `@reblu/ui` is `"private": true` so it is not
101
+ publishable and never will be the home for something an external SITE needs. The SDK is the
102
+ only place a decision like this gets made once.
103
+
104
+ ## What ships
105
+ - `RECAPTCHA_ACTION` — the one action name. `contact_form` over `submit_form` because it
106
+ names the form rather than the verb; a second protected form needs its own action, and
107
+ `submit_form` would have nothing left to call it. It buys comparable scores and nothing
108
+ more: base-api's `serverCaptchaVerify` checks only `success` and `score` and never reads
109
+ the `action` Google returns, so a token minted for another action verifies just as well.
110
+ Documented in place so nobody mistakes it for enforcement; binding it is a follow-up on
111
+ base-api.
112
+ - `resolveContactFormAvailability(analytics)` / `isContactFormAvailable(a)` — the render
113
+ gate, plus `ContactFormAvailability` and `ContactFormModeExhaustiveCheck`.
114
+ - `deferredRecaptchaKey(siteKey, armed)` — the interaction-mount key.
115
+ - `isSafeRecaptchaSiteKey` / `RECAPTCHA_SITE_KEY_PATTERN` — the site-key shape guard.
116
+
117
+ ## Which half lives where — the security split, stated
118
+
119
+ The **site key** is public by design: it travels to the browser inside the script URL, and
120
+ this module handles it. The **secret** never leaves base-api — it is stored per tenant in
121
+ `site_integration_config.recaptchaSecretKey` and read only by `serverCaptchaVerify` when
122
+ `POST /api/site/contact-form` runs.
123
+
124
+ **Nothing in this module, this package, or any `@reblu/site-*` package ever sees the secret
125
+ or calls Google's `siteverify`.** A SITE holding the secret would be a SITE that could be
126
+ tricked into approving its own tokens, and a distributable SDK is the worst possible place
127
+ to put a credential. The SITE mints a token and posts it as `ContactFormInput.captcha`;
128
+ base-api decides. That half already worked, and this changeset does not touch it.
129
+
130
+ ## The gate is an allow-list, and that is not a style preference
131
+
132
+ `resolveContactFormAvailability` accepts `DB_ONLY`, `WEBHOOK` and `RESEND`, never
133
+ `mode !== 'DISABLED'`. The deny-list version is a real incident: it rendered a form for
134
+ `NOT_CONFIGURED` too — which is what every freshly provisioned tenant carries until someone
135
+ finishes the wizard (REB-586) — so a visitor typed out a medical enquiry, pressed send, and
136
+ got `503 Contact form is disabled`. They learned the clinic could not be reached only after
137
+ writing to it, which is the worst possible order. An unknown mode, a mode from a base-api
138
+ newer than your SDK, and a config read that threw all resolve to the same answer: no form.
139
+ The page still shows the phone, the email and the address.
140
+
141
+ The key is checked for the same reason. `ContactFormInput.captcha` is required, so an
142
+ operational mode with no usable key is a form whose every submission is dead on arrival.
143
+ `missing-recaptcha-key` and `malformed-recaptcha-key` are separate reasons because they call
144
+ for different fixes by different people.
145
+
146
+ The list is a deliberate duplicate of base-api's `CONTACT_FORM_OPERATIONAL_MODES` — neither
147
+ side may import the other across that trust boundary. **This change does not delete the
148
+ existing copies**, so the list is now written three times: base-api's (authoritative), the
149
+ SDK's, and `apps/demo-site/src/lib/contact-form-mode.ts` (the reference SITE, not yet
150
+ migrated). All three are pinned by
151
+ `apps/base-api/tests/unit/lib/contracts/contact-form-mode-drift.test.ts`, on the same
152
+ pattern as REB-617's analytics-id guard: same members and same order for the two ordered
153
+ lists, same member set for demo-site's `Record`.
154
+
155
+ The type system does not cover this. The SDK's compile-time exhaustiveness check fires only
156
+ when the `ContactFormMode` union GAINS a member, and is blind to base-api NARROWING which
157
+ modes it accepts. Dropping `WEBHOOK` from base-api would otherwise leave every SITE
158
+ rendering a form base-api has stopped accepting — the REB-586 incident, reintroduced
159
+ through the back door. The SDK and base-api also now share one name for the concept; a
160
+ second name for one thing is how the extra copies got made in the first place.
161
+
162
+ ## The site-key guard, and its deliberate looseness
163
+
164
+ `recaptchaSiteKey` is the only tenant-controlled field on `/api/site/analytics` with **no
165
+ validation at any layer** — not the nexus form, not the route handler, not the response
166
+ contract. `gaId` and `clarityId` got all three in REB-617; this one was left behind. Its
167
+ severity is lower (the value lands in a script `src` query parameter, not an executing
168
+ script body, so it is not the same XSS sink), but the reliability hole is real: a mistyped
169
+ key renders a form that looks live and silently eats every enquiry.
170
+
171
+ So the SDK re-validates on read, exactly as `@reblu/site-next/analytics` already does for
172
+ the other two — base-api is not the only thing a SITE ever talks to. The pattern is a
173
+ **sink-safety** guard, not a guess at Google's key format: it excludes spaces, quotes,
174
+ newlines, `&` and `#`, and its length bounds (16–128) are far wider than the 40 characters
175
+ Google issues. A bound tight enough to be a real format check would be a bound tight enough
176
+ to take a working tenant offline the day Google changes its key length. The right place to
177
+ tighten is the nexus write path, where an owner can still see and fix the error.
178
+
179
+ ## Interaction-mount, verified against the vendor rather than asserted
180
+
181
+ `GoogleReCaptchaProvider` loads `api.js` the moment it mounts, so mounting it with the form
182
+ means every visitor who merely lands on the page to read the phone number has contacted
183
+ Google — on a clinic's contact page, a third-party request nobody asked for, on a route
184
+ whose whole subject is personal data.
185
+
186
+ `deferredRecaptchaKey` returns `''` until `armed`. An empty key mounts the provider and
187
+ injects nothing; swapping the real key in re-runs the library's effect and injects it then,
188
+ **without remounting the provider's children**, so the visitor does not lose focus or a
189
+ half-typed word at the instant the script arrives.
190
+
191
+ Both of those are claims about somebody else's code, and prose asserting them is worthless —
192
+ the library could inject eagerly in its next version and every comment here would quietly
193
+ become a lie while CI stayed green. So `tests/recaptcha-deferred-oracle.test.tsx` drives the
194
+ **real** `react-google-recaptcha-v3` and counts `<script>` tags in the DOM, including a
195
+ control proving a key supplied on the first render _does_ inject. If interaction-mount ever
196
+ stops working, that test goes red.
197
+
198
+ **This is not cookie consent.** Deferring the script narrows the exposure; it does not gate
199
+ it on a lawful basis. That remains REB-642, and this module says so where someone reaching
200
+ for it will read it.
201
+
202
+ ## Zero dependencies, and no `'use client'`
203
+
204
+ The subpath imports **nothing** — not React, not `next`, and notably not
205
+ `react-google-recaptcha-v3`, which stays the consuming SITE's dependency. It also carries no
206
+ `'use client'` directive, because the same functions run on both sides of the RSC boundary:
207
+ the server page resolves availability, the client component arms the key.
208
+
209
+ Both properties are gated by `tests/recaptcha-subpath-isolation.test.ts`, which runs a real
210
+ tsup build and asserts on the emitted bytes — the same enforcement `./cache` (REB-489),
211
+ `./json-ld` (REB-622) and `./analytics` (REB-617) already have.
212
+
213
+ ## Adoption
214
+
215
+ Additive — no existing export changes, nothing breaks. SITE repos replace their hand-written
216
+ availability check and provider wiring with `resolveContactFormAvailability` +
217
+ `deferredRecaptchaKey`, and switch `executeRecaptcha(...)` to `RECAPTCHA_ACTION`. Note that
218
+ switching the action re-baselines that SITE's reCAPTCHA score history, because Google charts
219
+ each action separately.
220
+
221
+ `apps/demo-site` is **not** migrated here — its form is a ~500-line hand-written component
222
+ built on `@reblu/ui` and its own schema, and folding that into this change would bury the
223
+ SDK surface in an unrelated refactor. Its `contact-form-mode.ts` is re-labelled instead: it
224
+ used to call itself "the template every external SITE repo copies", which this change makes
225
+ wrong, since it lacks the site-key check and its page gate only tests `!!recaptchaSiteKey`.
226
+ Migrating it is a follow-up.
227
+
228
+ ## 1.5.2
229
+
230
+ ### Patch Changes
231
+
232
+ - Updated dependencies [65e124a]
233
+ - @reblu/site-ui@0.5.0
234
+
235
+ ## 1.5.1
236
+
237
+ ### Patch Changes
238
+
239
+ - Updated dependencies [e5cda17]
240
+ - Updated dependencies [7dcfb4f]
241
+ - @reblu/site-ui@0.4.0
242
+ - @reblu/site-contracts@0.5.0
243
+ - @reblu/site-client@4.0.0
244
+
245
+ ## 1.5.0
246
+
247
+ ### Minor Changes
248
+
249
+ - 39ea223: Add `<AnalyticsScripts>` and the analytics id validators (`@reblu/site-next/analytics`)
250
+ — a safe renderer for tenant-configured Google Analytics and Microsoft Clarity tags.
251
+
252
+ Every SITE built on this SDK interpolated the tenant's analytics ids straight into
253
+ the body of an executing `<script>`:
254
+
255
+ ```js
256
+ gtag('config','${analytics.gaId}');
257
+ …"clarity","script","${analytics.clarityId}");
258
+ ```
259
+
260
+ No validation, no escaping, no sanitiser anywhere in the path. A `gaId` of
261
+ `x');alert(document.cookie);//` closes the string literal, closes the call,
262
+ comments out the remainder, and runs arbitrary JS on the tenant's OWN ORIGIN — on
263
+ every page including checkout, same origin as the portal session cookie. That is
264
+ the same trust boundary REB-613 closed on the sink next door (nexus-authored HTML
265
+ reaching `dangerouslySetInnerHTML`), except this one reached the JS interpreter
266
+ directly.
267
+
268
+ `<AnalyticsScripts gaId clarityId />` applies two independent layers:
269
+ 1. **Validation** — each id must match its vendor-documented shape
270
+ (`GA_MEASUREMENT_ID_PATTERN` = `/^G-[A-Z0-9]{1,32}$/`, per Google Analytics Help
271
+ 12270356; `CLARITY_PROJECT_ID_PATTERN` = `/^[a-z0-9]{1,32}$/`, per Microsoft
272
+ Learn's published demo project `3t0wlogvdz`). A non-matching id renders NO
273
+ script at all — analytics silently does not load and the site keeps serving.
274
+ No minimum length is invented: Google's own docs print the short `G-12345`.
275
+ 2. **Escaping** — the id is emitted as a JSON string literal through the existing
276
+ `jsonLdScriptContent` escaper, so even a value that skipped layer 1 (a future
277
+ field with a looser shape, a caller reaching for `gaInitScript` directly) is
278
+ inert data rather than code.
279
+
280
+ Both layers are proven load-bearing by mutation: removing the escaper makes the
281
+ built body actually call `alert` under test; removing the validation makes the
282
+ hostile id reach the `googletagmanager` loader URL.
283
+
284
+ Also exported for consumers that render their own tags: `isValidGaMeasurementId`,
285
+ `isValidClarityProjectId`, `gaInitScript`, `clarityInitScript`,
286
+ `analyticsScriptLiteral`.
287
+
288
+ Ships as its own tsup entry (`./analytics`), isolated from the root `index` barrel
289
+ the same way `./json-ld`, `./cache` and `./route-handlers` are — its only runtime
290
+ dependencies are `next/script` and the local escaper, so importing it must never
291
+ drag in `CtaButtons`/`EditableCta`'s `@reblu/site-ui` + `next/link` client graph.
292
+
293
+ **Action required for SITE repos:** replace hand-rolled GA/Clarity `<Script>`
294
+ blocks with `<AnalyticsScripts gaId={analytics?.gaId} clarityId={analytics?.clarityId} />`.
295
+ `apps/demo-site` migrates onto it in this same change. base-api additionally now
296
+ refuses to store a malformed id (nexus form) and coerces one to `null` on the way
297
+ out of `/api/site/analytics`, so a row written before this fix cannot reach a SITE.
298
+
299
+ ## 1.4.0
300
+
301
+ ### Minor Changes
302
+
303
+ - fe8b8d9: Add `jsonLdScriptContent(value)` and `<JsonLd>` (`@reblu/site-next/json-ld`) — a
304
+ safe `application/ld+json` `<script>` serialiser/renderer for the App Router.
305
+
306
+ A `<script>` element is HTML's "raw text" context: the parser ends it on the
307
+ first case-insensitive `</script` byte sequence it sees, no matter whether that
308
+ sequence sits inside a JSON string. `JSON.stringify` escapes for JSON syntax
309
+ only, so serialising tenant-authored data (page title, description, …) straight
310
+ into a JSON-LD `<script>` with plain `JSON.stringify` is a stored-XSS sink: a
311
+ value containing `</script><img src=x onerror=alert(1)>` breaks out of the
312
+ element and runs. `apps/demo-site` already carried a local fix for exactly this
313
+ (`generateStructuredData`, escaping `<` only); it has now been extracted here so
314
+ every SITE gets it instead of reinventing it — `demo-site` migrates onto the
315
+ shared helper in this same change.
316
+
317
+ `jsonLdScriptContent` escapes `<`, `>`, `&`, U+2028 and U+2029 to their
318
+ `\uXXXX` form. Every one of those is a standard JSON character escape, so
319
+ `JSON.parse(jsonLdScriptContent(value))` always recovers `value` unchanged —
320
+ this is an escaper, not a strip/allowlist sanitiser. `<` alone closes the
321
+ attack; `>`/`&`/U+2028/U+2029 are defence in depth appropriate for published
322
+ SDK surface (U+2028/U+2029 are valid JSON but invalid raw JS line terminators,
323
+ which has tripped up naive `<script>` consumers before).
324
+
325
+ ## Why `@reblu/site-next`, not `@reblu/site-blocks`
326
+
327
+ `@reblu/site-blocks` already ships `sanitizeHtml` for the **HTML** boundary
328
+ (`dangerouslySetInnerHTML`), which looks like the obvious home. It isn't: that
329
+ package's main entry bundles `parse5` + the `hast-util-*` chain + `marked` with
330
+ `treeshake: false` — `dist/index.js` is ~463 KB unminified, and PR #848 spent a
331
+ full round removing exactly that weight (89 KB gzipped) from a client bundle
332
+ that only needed one HTML-escaping helper. A 40-line, dependency-free escaper
333
+ must not drag an HTML parser behind it.
334
+
335
+ `<JsonLd>` is also Next-specific (the official App Router
336
+ `suppressHydrationWarning` pattern), which `@reblu/site-next` already is.
337
+ It ships as its own tsup entry (`./json-ld`), isolated from the root `index`
338
+ barrel the same way `./cache` and `./route-handlers` already are — importing
339
+ it must never drag in `CtaButtons`/`EditableCta`'s `next/link` +
340
+ `@reblu/site-ui` graph.
341
+
342
+ **Measured**: `dist/json-ld.js` is 701 B (451 B gzipped); its only runtime
343
+ import is `react/jsx-runtime`.
344
+
345
+ ## Usage
346
+
347
+ ```tsx
348
+ import { JsonLd } from '@reblu/site-next/json-ld'
349
+ ;<JsonLd id='page' data={page.jsonLd} />
350
+ ```
351
+
352
+ Or serialise directly:
353
+
354
+ ```ts
355
+ import { jsonLdScriptContent } from '@reblu/site-next/json-ld'
356
+
357
+ const html = jsonLdScriptContent(structuredData)
358
+ ```
359
+
360
+ See `@reblu/site-blocks`' README ("Security boundaries") and
361
+ `@reblu/site-next`'s README ("Structured data (JSON-LD)") for the full
362
+ HTML-boundary vs. script-boundary pairing.
363
+
364
+ ### Patch Changes
365
+
366
+ - Updated dependencies [eef13bf]
367
+ - @reblu/site-contracts@0.4.0
368
+ - @reblu/site-client@3.0.0
369
+
370
+ ## 1.3.2
371
+
372
+ ### Patch Changes
373
+
374
+ - Updated dependencies [495d32d]
375
+ - @reblu/site-contracts@0.3.0
376
+ - @reblu/site-client@2.0.0
377
+
378
+ ## 1.3.1
379
+
380
+ ### Patch Changes
381
+
382
+ - Updated dependencies [d081f12]
383
+ - Updated dependencies [06de596]
384
+ - @reblu/site-contracts@0.2.0
385
+ - @reblu/site-client@1.0.0
386
+
387
+ ## 1.3.0
388
+
389
+ ### Minor Changes
390
+
391
+ - 33da822: `AuthLogo` is now "logo XOR name" and owns the auth page's heading. When a logo
392
+ resolves for the active theme it renders that image wrapped in a heading whose
393
+ accessible name is `siteName` (via the img `alt`) — so the page always has a
394
+ heading, but the name is never shown as duplicated visible text next to the
395
+ logo. When no logo resolves it renders `siteName` (or the fallback label) as the
396
+ heading text instead of a plain `<span>`. Auth pages should therefore drop their
397
+ separate `<h1>{siteName}</h1>` — keeping it double-rendered the name whenever no
398
+ logo was configured (or only the other theme's logo was).
399
+
400
+ Adds an optional `headingLevel` prop (default `1`) to control that heading
401
+ level. The decorative escape hatch is unchanged: `hideTextFallback` or
402
+ `copy={{ altText: '' }}` still render a bare, unwrapped image for consumers that
403
+ own the heading themselves.
404
+
405
+ ### Patch Changes
406
+
407
+ - Updated dependencies [33da822]
408
+ - @reblu/site-ui@0.3.1
409
+
410
+ ## 1.2.1
411
+
412
+ ### Patch Changes
413
+
414
+ - b0cbc4c: Reclassify `@reblu/site-ui` from a `peerDependency` to a regular `dependency` (`workspace:^`), matching its siblings `@reblu/site-client` and `@reblu/site-contracts`.
415
+
416
+ `@reblu/site-ui` is an internal presentational primitive that `@reblu/site-next` bundles usage of, not a host-provided singleton — unlike `react`/`next`/`next-themes`, which remain peers. Declaring it a peer pinned it to an exact version (`workspace:*` → `0.2.0`), so shipping `@reblu/site-ui@0.3.0` (with the `./tabs`/`./label` subpaths the `./auth` bundle imports) fell out of range and forced a spurious major bump. As a regular dependency (`^0.3.0`) consuming SITEs no longer install `@reblu/site-ui` themselves, resolution to the version carrying `./tabs`/`./label` is automatic, and `@reblu/site-next` stays on 1.x (REB-510).
417
+
418
+ - Updated dependencies [b0cbc4c]
419
+ - @reblu/site-ui@0.3.0
420
+
421
+ ## 1.2.0
422
+
423
+ ### Minor Changes
424
+
425
+ - 14dd092: Add `AuthLogo` + 4 net-new auth components + `createProxyRoute` factory to `@reblu/site-next/auth` and `@reblu/site-next/route-handlers` (REB-510 slice 2b).
426
+
427
+ `./auth` now also exports:
428
+ - `AuthLogo` — dynamic tenant logo (light/dark × expanded/compact), driven by
429
+ `next-themes` and the 4 `/api/site/config` logo URLs, falling back to
430
+ `siteName` then a generic label when no logo is configured.
431
+ - `ResetPasswordRequest` / `ResetPasswordConfirm` (incl. the REB-378 MFA
432
+ step-up branch) / `SetPassword` / `AuthConfirm` (dual-mode
433
+ `success=true` vs `token_hash`+`type`) — copy-injectable with Spanish
434
+ defaults, extracted from demo-site's hand-rolled auth pages.
435
+
436
+ A new `./route-handlers` subpath (server-only, dependency-free, no
437
+ `'use client'`) exports `createProxyRoute(targetPath, opts?)`, a factory that
438
+ wires a SITE's `app/api/*` Route Handler to base-api's `/api/site/auth/*` in
439
+ one line — forwarding User-Agent/X-Forwarded-For/X-Real-IP, optionally
440
+ `Set-Cookie`, with a `requireJson` guard and a lazy per-request
441
+ `REBLU_API_BASE_URL`/`REBLU_API_KEY` env read (opts still take priority).
442
+
443
+ `next-themes` is now a peer dependency (only required by `AuthLogo`).
444
+
445
+ - ebe163e: Add a UI-free `./cache` subpath for `NextCacheAdapter` and float the SDK deps.
446
+
447
+ `@reblu/site-next@1.0.0` only exposed `.` and `./cta-buttons`. The barrel `.`
448
+ re-exports `NextCacheAdapter` alongside the CTA client components
449
+ (`CtaButtons`/`StickyMobileCta`/`EditableCta` → `@reblu/site-ui` + `next/link`),
450
+ so importing the server-only, dependency-free adapter from a server module still
451
+ dragged in the client boundary — Next 16 + Turbopack RSC then blew up with
452
+ `createContext is not a function` (same class as the Radix-Slot bug, REB-469).
453
+ - Add a dedicated `./cache` subpath export mapping only to
454
+ `next-cache-adapter.ts` (its own `tsup` entry). `NextCacheAdapter` is now
455
+ importable as `@reblu/site-next/cache` without pulling any UI. The barrel `.`
456
+ keeps exporting it for backward compatibility.
457
+ - Float `@reblu/site-client` and `@reblu/site-contracts` from `workspace:*` to
458
+ `workspace:^`, so a published `@reblu/site-next` resolves `^0.1.1` (the
459
+ republished packages that ship `dist/`, REB-487) instead of the exact,
460
+ broken `0.1.0` that `workspace:*` had frozen at publish time.
461
+
462
+ ### Patch Changes
463
+
464
+ - Updated dependencies [356e04d]
465
+ - @reblu/site-contracts@0.1.2
466
+ - @reblu/site-client@0.1.2
467
+
468
+ ## 1.0.0
469
+
470
+ ### Patch Changes
471
+
472
+ - Updated dependencies [873cad4]
473
+ - @reblu/site-ui@0.2.0
package/README.md CHANGED
@@ -18,6 +18,9 @@ intentionally Next-coupled so the lower SDK layers —
18
18
  5. **reCAPTCHA v3** (`./recaptcha`) — the SITE's half of the contact-form
19
19
  captcha contract: the canonical action, the render gate, and the
20
20
  interaction-mount key. See "reCAPTCHA v3" below.
21
+ 6. **Academy** (`./academy`) — `AcademyEnrollmentForm`, the anonymous
22
+ enrollment-request form, plus `deriveValidationFields` to build its
23
+ per-tenant validation fields from course/config data. See "Academy" below.
21
24
 
22
25
  ## Install
23
26
 
@@ -149,6 +152,71 @@ export const POST = createProxyRoute('/api/site/auth/login')
149
152
  `REBLU_API_BASE_URL`/`REBLU_API_KEY` env vars — read lazily per request, not at
150
153
  module load.
151
154
 
155
+ ## Academy
156
+
157
+ `@reblu/site-next/academy` exports the anonymous academy enrollment-request
158
+ form and the pure helper that derives its validation fields from tenant data.
159
+
160
+ ```tsx
161
+ import { AcademyEnrollmentForm, deriveValidationFields } from '@reblu/site-next/academy'
162
+
163
+ // `course` is whatever `GET /api/site/academy/courses/[slug]` (via
164
+ // `@reblu/site-client`'s `academy.bySlug`) already returned — this form never
165
+ // re-derives `salesEnabled` or requests anything extra.
166
+ const validationFields = deriveValidationFields(course)
167
+
168
+ {course.salesEnabled && (
169
+ <AcademyEnrollmentForm
170
+ enrollmentRequestUrl="/api/academy/enrollment-requests"
171
+ courseSlug={course.slug}
172
+ validationFields={validationFields}
173
+ />
174
+ )}
175
+ ```
176
+
177
+ `enrollmentRequestUrl` is a SITE-owned Route Handler — the browser can never
178
+ call base-api directly (`X-Reblu-Api-Key` is a server secret). Wire one with
179
+ `createProxyRoute` from `./route-handlers`:
180
+
181
+ ```ts
182
+ // app/api/academy/enrollment-requests/route.ts
183
+ import { createProxyRoute } from '@reblu/site-next/route-handlers'
184
+
185
+ export const POST = createProxyRoute('/api/site/academy/enrollment-requests')
186
+ ```
187
+
188
+ **PER-TENANT, DATA-DRIVEN — not code-driven.** Whether a course asks for extra
189
+ verification, and which fields it asks for, is entirely tenant configuration:
190
+
191
+ - Today, `AcademyCourseDetail` exposes only `requiresValidation` +
192
+ `validationFieldLabel` (a single generic field). `deriveValidationFields`
193
+ falls back to that one field when no richer list is present.
194
+ - A future tenant-configurable list (e.g. `AcademyConfig.validationFields: [{
195
+ key, label, required, maxLength? }]`) is accepted by the SAME helper —
196
+ `AcademyValidationSource` is defined structurally, so a wider real contract
197
+ type is already assignable without an SDK release. Arbitrary keys (a
198
+ colegiado number, a professional college, anything a tenant configures) work
199
+ the moment the data carries them; `AcademyEnrollmentForm` never hardcodes a
200
+ field name — it only ever iterates the `validationFields` array it was given.
201
+ - A tenant with no validation requirement passes `validationFields={[]}` (or
202
+ omits the prop, `[]` is the default) — the form renders only the email
203
+ field.
204
+
205
+ **Never a purchase confirmation.** The upstream route
206
+ (`POST /api/site/academy/enrollment-requests`) replies UNIFORMLY whether the
207
+ email was brand-new (a request row was created) or already known (a sign-in
208
+ link was sent, nothing created) — anti-enumeration by design. The form's
209
+ always-visible copy says the request goes to human review and that no
210
+ automatic charge happens; `onSubmitted` fires on any 2xx and must never be
211
+ read as "a request was recorded".
212
+
213
+ **Field-level errors.** A non-2xx JSON body shaped like `{ error, details:
214
+ { fieldErrors } }` (`ApiErrorBody`/`ApiErrorDetails` from `@reblu/site-client`)
215
+ maps each `fieldErrors` key onto the matching input (`email`, or a
216
+ `validationFields[].key`) and focuses the first one; anything else — no
217
+ `details`, or a `429` — renders as a single form-level error instead. No
218
+ reCAPTCHA: the route is IP rate-limited server-side.
219
+
152
220
  ## Structured data (JSON-LD)
153
221
 
154
222
  `@reblu/site-next/json-ld` (REB-622, server-only, dependency-free) renders one