@brandlab-digital/site-next 3.12.1 → 3.14.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.
Files changed (208) hide show
  1. package/README.md +132 -0
  2. package/dist/contact/delivery.d.ts +81 -0
  3. package/dist/contact/delivery.d.ts.map +1 -0
  4. package/dist/contact/delivery.js +138 -0
  5. package/dist/contact/delivery.js.map +1 -0
  6. package/dist/contact/resend-webhook.d.ts +72 -0
  7. package/dist/contact/resend-webhook.d.ts.map +1 -0
  8. package/dist/contact/resend-webhook.js +300 -0
  9. package/dist/contact/resend-webhook.js.map +1 -0
  10. package/dist/contact/route-handler.d.ts +16 -0
  11. package/dist/contact/route-handler.d.ts.map +1 -1
  12. package/dist/contact/route-handler.js +8 -0
  13. package/dist/contact/route-handler.js.map +1 -1
  14. package/dist/contact/server-entry.d.ts +2 -0
  15. package/dist/contact/server-entry.d.ts.map +1 -1
  16. package/dist/contact/server-entry.js +2 -0
  17. package/dist/contact/server-entry.js.map +1 -1
  18. package/dist/contact/server.d.ts +26 -0
  19. package/dist/contact/server.d.ts.map +1 -1
  20. package/dist/contact/server.js +48 -3
  21. package/dist/contact/server.js.map +1 -1
  22. package/dist/estate-segment.d.ts +48 -0
  23. package/dist/estate-segment.d.ts.map +1 -0
  24. package/dist/estate-segment.js +54 -0
  25. package/dist/estate-segment.js.map +1 -0
  26. package/dist/google-business-reviews-revalidate.d.ts +61 -0
  27. package/dist/google-business-reviews-revalidate.d.ts.map +1 -0
  28. package/dist/google-business-reviews-revalidate.js +151 -0
  29. package/dist/google-business-reviews-revalidate.js.map +1 -0
  30. package/dist/google-business-reviews-server.d.ts +21 -2
  31. package/dist/google-business-reviews-server.d.ts.map +1 -1
  32. package/dist/google-business-reviews-server.js +21 -4
  33. package/dist/google-business-reviews-server.js.map +1 -1
  34. package/dist/headers.d.ts.map +1 -1
  35. package/dist/headers.js +6 -0
  36. package/dist/headers.js.map +1 -1
  37. package/dist/next-config.d.ts +17 -1
  38. package/dist/next-config.d.ts.map +1 -1
  39. package/dist/next-config.js +36 -2
  40. package/dist/next-config.js.map +1 -1
  41. package/dist/robots.d.ts +26 -4
  42. package/dist/robots.d.ts.map +1 -1
  43. package/dist/robots.js +49 -2
  44. package/dist/robots.js.map +1 -1
  45. package/dist/ui-kit/brandlab-skin.d.ts +24 -1
  46. package/dist/ui-kit/brandlab-skin.d.ts.map +1 -1
  47. package/dist/ui-kit/brandlab-skin.js.map +1 -1
  48. package/dist/ui-kit/form/photo-upload.d.ts +4 -1
  49. package/dist/ui-kit/form/photo-upload.d.ts.map +1 -1
  50. package/dist/ui-kit/form/photo-upload.js +4 -4
  51. package/dist/ui-kit/form/photo-upload.js.map +1 -1
  52. package/dist/ui-kit/form/quote-form.d.ts +4 -1
  53. package/dist/ui-kit/form/quote-form.d.ts.map +1 -1
  54. package/dist/ui-kit/form/quote-form.js.map +1 -1
  55. package/dist/ui-kit/form/thank-you.d.ts +4 -1
  56. package/dist/ui-kit/form/thank-you.d.ts.map +1 -1
  57. package/dist/ui-kit/form/thank-you.js +2 -2
  58. package/dist/ui-kit/form/thank-you.js.map +1 -1
  59. package/dist/ui-kit/icon-glyphs.d.ts +41 -0
  60. package/dist/ui-kit/icon-glyphs.d.ts.map +1 -0
  61. package/dist/ui-kit/icon-glyphs.js +138 -0
  62. package/dist/ui-kit/icon-glyphs.js.map +1 -0
  63. package/dist/ui-kit/icon-sprite.d.ts +19 -0
  64. package/dist/ui-kit/icon-sprite.d.ts.map +1 -0
  65. package/dist/ui-kit/icon-sprite.js +44 -0
  66. package/dist/ui-kit/icon-sprite.js.map +1 -0
  67. package/dist/ui-kit/icons.d.ts +38 -3
  68. package/dist/ui-kit/icons.d.ts.map +1 -1
  69. package/dist/ui-kit/icons.js +47 -64
  70. package/dist/ui-kit/icons.js.map +1 -1
  71. package/dist/ui-kit/prefetch.d.ts +50 -0
  72. package/dist/ui-kit/prefetch.d.ts.map +1 -0
  73. package/dist/ui-kit/prefetch.js +50 -0
  74. package/dist/ui-kit/prefetch.js.map +1 -0
  75. package/dist/ui-kit/primitives/badge.d.ts +25 -0
  76. package/dist/ui-kit/primitives/badge.d.ts.map +1 -0
  77. package/dist/ui-kit/primitives/badge.js +21 -0
  78. package/dist/ui-kit/primitives/badge.js.map +1 -0
  79. package/dist/ui-kit/primitives/breadcrumbs.d.ts +27 -0
  80. package/dist/ui-kit/primitives/breadcrumbs.d.ts.map +1 -0
  81. package/dist/ui-kit/primitives/breadcrumbs.js +40 -0
  82. package/dist/ui-kit/primitives/breadcrumbs.js.map +1 -0
  83. package/dist/ui-kit/primitives/button.d.ts +32 -0
  84. package/dist/ui-kit/primitives/button.d.ts.map +1 -0
  85. package/dist/ui-kit/primitives/button.js +8 -0
  86. package/dist/ui-kit/primitives/button.js.map +1 -0
  87. package/dist/ui-kit/primitives/card.d.ts +40 -0
  88. package/dist/ui-kit/primitives/card.d.ts.map +1 -0
  89. package/dist/ui-kit/primitives/card.js +8 -0
  90. package/dist/ui-kit/primitives/card.js.map +1 -0
  91. package/dist/ui-kit/primitives/index.d.ts +12 -2
  92. package/dist/ui-kit/primitives/index.d.ts.map +1 -1
  93. package/dist/ui-kit/primitives/index.js +12 -2
  94. package/dist/ui-kit/primitives/index.js.map +1 -1
  95. package/dist/ui-kit/primitives/layout.d.ts +12 -0
  96. package/dist/ui-kit/primitives/layout.d.ts.map +1 -1
  97. package/dist/ui-kit/primitives/layout.js +20 -2
  98. package/dist/ui-kit/primitives/layout.js.map +1 -1
  99. package/dist/ui-kit/primitives/links.d.ts +28 -2
  100. package/dist/ui-kit/primitives/links.d.ts.map +1 -1
  101. package/dist/ui-kit/primitives/links.js +11 -6
  102. package/dist/ui-kit/primitives/links.js.map +1 -1
  103. package/dist/ui-kit/primitives/lists.d.ts +43 -0
  104. package/dist/ui-kit/primitives/lists.d.ts.map +1 -0
  105. package/dist/ui-kit/primitives/lists.js +34 -0
  106. package/dist/ui-kit/primitives/lists.js.map +1 -0
  107. package/dist/ui-kit/primitives/media-frame.d.ts +28 -0
  108. package/dist/ui-kit/primitives/media-frame.d.ts.map +1 -0
  109. package/dist/ui-kit/primitives/media-frame.js +58 -0
  110. package/dist/ui-kit/primitives/media-frame.js.map +1 -0
  111. package/dist/ui-kit/primitives/media.d.ts +38 -0
  112. package/dist/ui-kit/primitives/media.d.ts.map +1 -0
  113. package/dist/ui-kit/primitives/media.js +9 -0
  114. package/dist/ui-kit/primitives/media.js.map +1 -0
  115. package/dist/ui-kit/primitives/quote-button.d.ts +4 -1
  116. package/dist/ui-kit/primitives/quote-button.d.ts.map +1 -1
  117. package/dist/ui-kit/primitives/quote-button.js +3 -2
  118. package/dist/ui-kit/primitives/quote-button.js.map +1 -1
  119. package/dist/ui-kit/primitives/stat.d.ts +25 -0
  120. package/dist/ui-kit/primitives/stat.d.ts.map +1 -0
  121. package/dist/ui-kit/primitives/stat.js +7 -0
  122. package/dist/ui-kit/primitives/stat.js.map +1 -0
  123. package/dist/ui-kit/repeated-slots.d.ts +121 -0
  124. package/dist/ui-kit/repeated-slots.d.ts.map +1 -0
  125. package/dist/ui-kit/repeated-slots.js +81 -0
  126. package/dist/ui-kit/repeated-slots.js.map +1 -0
  127. package/dist/ui-kit/sections/accordion.d.ts +4 -1
  128. package/dist/ui-kit/sections/accordion.d.ts.map +1 -1
  129. package/dist/ui-kit/sections/accordion.js +2 -2
  130. package/dist/ui-kit/sections/accordion.js.map +1 -1
  131. package/dist/ui-kit/sections/contact-block.d.ts +4 -1
  132. package/dist/ui-kit/sections/contact-block.d.ts.map +1 -1
  133. package/dist/ui-kit/sections/contact-block.js +3 -3
  134. package/dist/ui-kit/sections/contact-block.js.map +1 -1
  135. package/dist/ui-kit/sections/faqs.d.ts +4 -1
  136. package/dist/ui-kit/sections/faqs.d.ts.map +1 -1
  137. package/dist/ui-kit/sections/faqs.js +4 -5
  138. package/dist/ui-kit/sections/faqs.js.map +1 -1
  139. package/dist/ui-kit/sections/hero.d.ts +113 -0
  140. package/dist/ui-kit/sections/hero.d.ts.map +1 -0
  141. package/dist/ui-kit/sections/hero.js +58 -0
  142. package/dist/ui-kit/sections/hero.js.map +1 -0
  143. package/dist/ui-kit/sections/index.d.ts +2 -0
  144. package/dist/ui-kit/sections/index.d.ts.map +1 -1
  145. package/dist/ui-kit/sections/index.js +2 -0
  146. package/dist/ui-kit/sections/index.js.map +1 -1
  147. package/dist/ui-kit/sections/price-guide.d.ts +4 -1
  148. package/dist/ui-kit/sections/price-guide.d.ts.map +1 -1
  149. package/dist/ui-kit/sections/price-guide.js +3 -3
  150. package/dist/ui-kit/sections/price-guide.js.map +1 -1
  151. package/dist/ui-kit/sections/price-table.d.ts +7 -9
  152. package/dist/ui-kit/sections/price-table.d.ts.map +1 -1
  153. package/dist/ui-kit/sections/price-table.js +8 -8
  154. package/dist/ui-kit/sections/price-table.js.map +1 -1
  155. package/dist/ui-kit/sections/proof-strip.d.ts +4 -1
  156. package/dist/ui-kit/sections/proof-strip.d.ts.map +1 -1
  157. package/dist/ui-kit/sections/proof-strip.js +2 -2
  158. package/dist/ui-kit/sections/proof-strip.js.map +1 -1
  159. package/dist/ui-kit/sections/reviews-band.d.ts +5 -2
  160. package/dist/ui-kit/sections/reviews-band.d.ts.map +1 -1
  161. package/dist/ui-kit/sections/reviews-band.js +2 -2
  162. package/dist/ui-kit/sections/reviews-band.js.map +1 -1
  163. package/dist/ui-kit/sections/services-grid.d.ts +82 -0
  164. package/dist/ui-kit/sections/services-grid.d.ts.map +1 -0
  165. package/dist/ui-kit/sections/services-grid.js +90 -0
  166. package/dist/ui-kit/sections/services-grid.js.map +1 -0
  167. package/dist/ui-kit/sections/site-footer.d.ts +4 -1
  168. package/dist/ui-kit/sections/site-footer.d.ts.map +1 -1
  169. package/dist/ui-kit/sections/site-footer.js +9 -6
  170. package/dist/ui-kit/sections/site-footer.js.map +1 -1
  171. package/dist/ui-kit/shell/current-link.d.ts +15 -1
  172. package/dist/ui-kit/shell/current-link.d.ts.map +1 -1
  173. package/dist/ui-kit/shell/current-link.js +10 -1
  174. package/dist/ui-kit/shell/current-link.js.map +1 -1
  175. package/dist/ui-kit/shell/index.d.ts +1 -0
  176. package/dist/ui-kit/shell/index.d.ts.map +1 -1
  177. package/dist/ui-kit/shell/index.js +1 -0
  178. package/dist/ui-kit/shell/index.js.map +1 -1
  179. package/dist/ui-kit/shell/intent-prefetch.d.ts +4 -0
  180. package/dist/ui-kit/shell/intent-prefetch.d.ts.map +1 -0
  181. package/dist/ui-kit/shell/intent-prefetch.js +61 -0
  182. package/dist/ui-kit/shell/intent-prefetch.js.map +1 -0
  183. package/dist/ui-kit/shell/mobile-drawer.d.ts +7 -3
  184. package/dist/ui-kit/shell/mobile-drawer.d.ts.map +1 -1
  185. package/dist/ui-kit/shell/mobile-drawer.js +20 -4
  186. package/dist/ui-kit/shell/mobile-drawer.js.map +1 -1
  187. package/dist/ui-kit/shell/site-header.d.ts +4 -1
  188. package/dist/ui-kit/shell/site-header.d.ts.map +1 -1
  189. package/dist/ui-kit/shell/site-header.js +9 -10
  190. package/dist/ui-kit/shell/site-header.js.map +1 -1
  191. package/dist/ui-kit/shell/social-links.d.ts +1 -2
  192. package/dist/ui-kit/shell/social-links.d.ts.map +1 -1
  193. package/dist/ui-kit/shell/social-links.js +6 -3
  194. package/dist/ui-kit/shell/social-links.js.map +1 -1
  195. package/dist/ui-kit/shell/utility-bar.d.ts +4 -1
  196. package/dist/ui-kit/shell/utility-bar.d.ts.map +1 -1
  197. package/dist/ui-kit/shell/utility-bar.js +2 -2
  198. package/dist/ui-kit/shell/utility-bar.js.map +1 -1
  199. package/dist/ui-kit/skin-index.d.ts +2 -1
  200. package/dist/ui-kit/skin-index.d.ts.map +1 -1
  201. package/dist/ui-kit/skin-index.js +2 -1
  202. package/dist/ui-kit/skin-index.js.map +1 -1
  203. package/dist/ui-kit/skin.d.ts +21 -4
  204. package/dist/ui-kit/skin.d.ts.map +1 -1
  205. package/dist/ui-kit/skin.js +14 -1
  206. package/dist/ui-kit/skin.js.map +1 -1
  207. package/dist/ui-kit/structure.css +383 -3
  208. package/package.json +10 -2
@@ -0,0 +1,61 @@
1
+ import { type BrandlabContactRateLimitRule } from './contact/rate-limit.js';
2
+ /**
3
+ * The signed route that refreshes a site's reviews the day they change (3.13.0). Serve it at
4
+ * `src/app/api/brandlab/reviews-revalidate/route.ts`:
5
+ *
6
+ * ```ts
7
+ * import { createBrandlabReviewsRevalidateRoute } from '@brandlab-digital/site-next/google-business-reviews/server'
8
+ *
9
+ * export const { POST } = createBrandlabReviewsRevalidateRoute({ site: 'hr-kitchens' })
10
+ * ```
11
+ *
12
+ * Once the route is deployed with its secret and the site is registered with the daily checker, the site passes
13
+ * `refresh: 'on-change'` to `getBusinessProfileReviews`, which lengthens the fetch's time-based refresh from a day to
14
+ * a 7-day safety net. Until then the fetch stays daily, so a site is never left on weekly reviews by a version bump.
15
+ *
16
+ * The call contract (the daily reviews checker's side):
17
+ * - The checker hashes the feed with site-core `hashBusinessProfileReviews` and keeps, per site, the last hash the route
18
+ * accepted with a 200. It calls only when today's hash differs, so the feed side decides what is a change; the route
19
+ * keeps the last hash in memory too, but only per server instance, so that is a second guard, not the record.
20
+ * - `POST /api/brandlab/reviews-revalidate`, body `{"site":"<slug>","hash":"<64 hex>"}` (at most 1 KB), headers
21
+ * `x-brandlab-timestamp: <Unix seconds>` and `x-brandlab-signature: v1=<hex HMAC-SHA256 of "<timestamp>.<body>">`
22
+ * under `BRANDLAB_REVALIDATE_SECRET`. site-core `createBusinessProfileReviewsRevalidateRequest` builds both.
23
+ * - Answers: 200 `{ ok: true, revalidated: true }` after `revalidateTag('brandlab-reviews:<site>', 'max')`; 200
24
+ * `{ ok: true, revalidated: false, reason: 'unchanged' }` when this instance already applied that hash; 400 for a
25
+ * malformed body or another site's slug; 401 for a missing or wrong signature or a timestamp more than 5 minutes
26
+ * off; 413 for a body over 1 KB; 429 with `Retry-After` once one client IP has failed those checks past the rate limit;
27
+ * 503 when the secret is not set; 500 when the refresh itself failed. The checker records the hash only after a 200
28
+ * and retries the rest the next day.
29
+ * - The signature and timestamp are checked before any rate limit, and only failed attempts count, keyed on the
30
+ * platform's `x-real-ip` (never the client-controlled `x-forwarded-for`). A correctly signed request is never
31
+ * refused with 429, and without `x-real-ip` nothing is rate-limited.
32
+ */
33
+ export interface BrandlabReviewsRevalidateRouteOptions {
34
+ /** The site's slug in the reviews feed, the same one `getBusinessProfileReviews` uses. */
35
+ site: string;
36
+ /** Defaults to `process.env`, read on each request: `BRANDLAB_REVALIDATE_SECRET` (at least 32 characters). */
37
+ environment?: Readonly<Record<string, string | undefined>>;
38
+ /** Defaults to Next's `revalidateTag` from `next/cache`. */
39
+ revalidateTag?: (tag: string, profile: 'max') => void | Promise<void>;
40
+ /**
41
+ * Failed attempts (bad signature, stale timestamp, oversized body) per client IP (`x-real-ip`) before the route answers
42
+ * 429, held in memory per server instance (default 10 in 10 minutes); `false` turns it off. Signed requests never count.
43
+ */
44
+ rateLimit?: BrandlabContactRateLimitRule | false;
45
+ /** Defaults to `Date.now`. */
46
+ now?: () => number;
47
+ }
48
+ export interface BrandlabReviewsRevalidateRoute {
49
+ POST(request: Request): Promise<Response>;
50
+ }
51
+ /** Failed attempts per client IP before the route answers 429; a correctly signed request is never limited. */
52
+ export declare const BRANDLAB_REVIEWS_REVALIDATE_RATE_LIMIT: Readonly<{
53
+ limit: 10;
54
+ windowMs: number;
55
+ }>;
56
+ /**
57
+ * Creates the signed reviews revalidate route for one site. Create it once at module level, so its rate-limit window
58
+ * and last-applied hash outlive each request.
59
+ */
60
+ export declare function createBrandlabReviewsRevalidateRoute(options: BrandlabReviewsRevalidateRouteOptions): BrandlabReviewsRevalidateRoute;
61
+ //# sourceMappingURL=google-business-reviews-revalidate.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"google-business-reviews-revalidate.d.ts","sourceRoot":"","sources":["../src/google-business-reviews-revalidate.ts"],"names":[],"mappings":"AAQA,OAAO,EAAoC,KAAK,4BAA4B,EAAE,MAAM,yBAAyB,CAAA;AAE7G;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,MAAM,WAAW,qCAAqC;IACpD,0FAA0F;IAC1F,IAAI,EAAE,MAAM,CAAA;IACZ,8GAA8G;IAC9G,WAAW,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC,CAAA;IAC1D,4DAA4D;IAC5D,aAAa,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,OAAO,EAAE,KAAK,KAAK,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAA;IACrE;;;OAGG;IACH,SAAS,CAAC,EAAE,4BAA4B,GAAG,KAAK,CAAA;IAChD,8BAA8B;IAC9B,GAAG,CAAC,EAAE,MAAM,MAAM,CAAA;CACnB;AAED,MAAM,WAAW,8BAA8B;IAC7C,IAAI,CAAC,OAAO,EAAE,OAAO,GAAG,OAAO,CAAC,QAAQ,CAAC,CAAA;CAC1C;AAED,+GAA+G;AAC/G,eAAO,MAAM,sCAAsC;;;EAA0D,CAAA;AAiE7G;;;GAGG;AACH,wBAAgB,oCAAoC,CAClD,OAAO,EAAE,qCAAqC,GAC7C,8BAA8B,CAgFhC"}
@@ -0,0 +1,151 @@
1
+ import { BUSINESS_PROFILE_REVIEWS_HASH_PATTERN, BUSINESS_PROFILE_REVIEWS_REVALIDATE_HEADERS, BUSINESS_PROFILE_REVIEWS_REVALIDATE_SECRET_NAME, businessProfileReviewsSiteTag, normaliseBusinessProfileReviewsRevalidateSecret, signBusinessProfileReviewsRevalidate, } from '@brandlab-digital/site-core/google-business-reviews';
2
+ import { createBrandlabContactRateLimiter } from './contact/rate-limit.js';
3
+ /** Failed attempts per client IP before the route answers 429; a correctly signed request is never limited. */
4
+ export const BRANDLAB_REVIEWS_REVALIDATE_RATE_LIMIT = Object.freeze({ limit: 10, windowMs: 10 * 60 * 1_000 });
5
+ const MAX_BODY_BYTES = 1_024;
6
+ const MAX_CLOCK_SKEW_SECONDS = 300;
7
+ const MIN_SECRET_LENGTH = 32;
8
+ const LOG_EVENT = 'brandlab_reviews_revalidate';
9
+ function json(status, body, headers = {}) {
10
+ return Response.json(body, { status, headers: { 'cache-control': 'no-store', ...headers } });
11
+ }
12
+ /** One JSON line per refused or applied request: never the secret, signature or body. */
13
+ function log(site, result, extra = {}) {
14
+ const line = JSON.stringify({ event: LOG_EVENT, site, result, ...extra });
15
+ if (result === 'revalidated' || result === 'unchanged')
16
+ console.info(line);
17
+ else
18
+ console.error(line);
19
+ }
20
+ /** Equal-length texts are compared in time that does not depend on where they differ. */
21
+ function sameText(a, b) {
22
+ let difference = a.length ^ b.length;
23
+ const reference = a.length === b.length ? b : a;
24
+ for (let index = 0; index < a.length; index += 1)
25
+ difference |= a.charCodeAt(index) ^ reference.charCodeAt(index);
26
+ return difference === 0;
27
+ }
28
+ /** The platform-set client IP (Vercel's `x-real-ip`); `x-forwarded-for` can be written by the client, so it is not read. */
29
+ function clientIp(request) {
30
+ const candidate = request.headers.get('x-real-ip')?.trim();
31
+ return candidate && candidate.length <= 64 && /^[0-9A-Fa-f:.]+$/.test(candidate) ? candidate : undefined;
32
+ }
33
+ /**
34
+ * The body as text, read at most `limit` bytes into the stream: a larger (or chunked, undeclared) body is cancelled
35
+ * as soon as it passes the limit, never buffered whole. `null` means it was too large.
36
+ */
37
+ async function readCappedText(request, limit) {
38
+ if (!request.body)
39
+ return '';
40
+ const reader = request.body.getReader();
41
+ const chunks = [];
42
+ let size = 0;
43
+ for (;;) {
44
+ const { done, value } = await reader.read();
45
+ if (done)
46
+ break;
47
+ size += value.byteLength;
48
+ if (size > limit) {
49
+ await reader.cancel().catch(() => undefined);
50
+ return null;
51
+ }
52
+ chunks.push(value);
53
+ }
54
+ const bytes = new Uint8Array(size);
55
+ let offset = 0;
56
+ for (const chunk of chunks) {
57
+ bytes.set(chunk, offset);
58
+ offset += chunk.byteLength;
59
+ }
60
+ return new TextDecoder().decode(bytes);
61
+ }
62
+ async function nextRevalidateTag(tag, profile) {
63
+ const { revalidateTag } = await import('next/cache');
64
+ revalidateTag(tag, profile);
65
+ }
66
+ /**
67
+ * Creates the signed reviews revalidate route for one site. Create it once at module level, so its rate-limit window
68
+ * and last-applied hash outlive each request.
69
+ */
70
+ export function createBrandlabReviewsRevalidateRoute(options) {
71
+ if (!options || typeof options !== 'object')
72
+ throw new TypeError('Reviews revalidate route options must be an object.');
73
+ const site = options.site;
74
+ const tag = businessProfileReviewsSiteTag(site);
75
+ const rule = options.rateLimit === undefined ? BRANDLAB_REVIEWS_REVALIDATE_RATE_LIMIT : options.rateLimit;
76
+ const limiter = rule === false ? undefined : createBrandlabContactRateLimiter({ perIp: rule, perAddress: false });
77
+ const revalidate = options.revalidateTag ?? nextRevalidateTag;
78
+ const now = options.now ?? Date.now;
79
+ let lastHash;
80
+ return Object.freeze({
81
+ async POST(request) {
82
+ const at = now();
83
+ // Only failed attempts count towards the limit, so a correctly signed checker is never locked out.
84
+ const refuse = (status, error, reason) => {
85
+ const ip = limiter ? clientIp(request) : undefined;
86
+ if (limiter && ip) {
87
+ // Each failed attempt counts once: a fresh id, since the route has no submission id to dedupe on.
88
+ const decision = limiter.check({ ipKey: ip, submissionId: crypto.randomUUID(), now: at });
89
+ if (!decision.ok) {
90
+ log(site, 'refused', { reason: 'rate-limited' });
91
+ return json(429, { ok: false, error: 'rate-limited' }, { 'retry-after': String(decision.retryAfterSeconds) });
92
+ }
93
+ }
94
+ log(site, 'refused', { reason });
95
+ return json(status, { ok: false, error });
96
+ };
97
+ const environment = options.environment ?? process.env;
98
+ const secret = normaliseBusinessProfileReviewsRevalidateSecret(environment[BUSINESS_PROFILE_REVIEWS_REVALIDATE_SECRET_NAME] ?? '');
99
+ if (!secret || secret.length < MIN_SECRET_LENGTH) {
100
+ log(site, 'refused', { reason: 'not-configured' });
101
+ return json(503, { ok: false, error: 'not-configured' });
102
+ }
103
+ const declaredLength = Number(request.headers.get('content-length') ?? '0');
104
+ if (Number.isFinite(declaredLength) && declaredLength > MAX_BODY_BYTES)
105
+ return refuse(413, 'too-large', 'too-large');
106
+ const body = await readCappedText(request, MAX_BODY_BYTES);
107
+ if (body === null)
108
+ return refuse(413, 'too-large', 'too-large');
109
+ const timestampText = request.headers.get(BUSINESS_PROFILE_REVIEWS_REVALIDATE_HEADERS.timestamp)?.trim() ?? '';
110
+ const signature = request.headers.get(BUSINESS_PROFILE_REVIEWS_REVALIDATE_HEADERS.signature)?.trim() ?? '';
111
+ const timestamp = /^\d{1,12}$/.test(timestampText) ? Number(timestampText) : Number.NaN;
112
+ if (!Number.isSafeInteger(timestamp) || Math.abs(at / 1_000 - timestamp) > MAX_CLOCK_SKEW_SECONDS) {
113
+ return refuse(401, 'unauthorised', 'timestamp');
114
+ }
115
+ const expected = await signBusinessProfileReviewsRevalidate({ secret, body, timestamp });
116
+ if (!sameText(signature, expected))
117
+ return refuse(401, 'unauthorised', 'signature');
118
+ let parsed;
119
+ try {
120
+ parsed = JSON.parse(body);
121
+ }
122
+ catch {
123
+ return json(400, { ok: false, error: 'invalid-body' });
124
+ }
125
+ const record = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
126
+ if (typeof record.hash !== 'string' || !BUSINESS_PROFILE_REVIEWS_HASH_PATTERN.test(record.hash)) {
127
+ return json(400, { ok: false, error: 'invalid-body' });
128
+ }
129
+ if (record.site !== site) {
130
+ log(site, 'refused', { reason: 'wrong-site' });
131
+ return json(400, { ok: false, error: 'wrong-site' });
132
+ }
133
+ const hash = record.hash;
134
+ if (hash === lastHash) {
135
+ log(site, 'unchanged', { hash: hash.slice(0, 12) });
136
+ return json(200, { ok: true, revalidated: false, reason: 'unchanged' });
137
+ }
138
+ try {
139
+ await revalidate(tag, 'max');
140
+ }
141
+ catch (error) {
142
+ log(site, 'failed', { reason: error instanceof Error ? error.name : 'error' });
143
+ return json(500, { ok: false, error: 'revalidate-failed' });
144
+ }
145
+ lastHash = hash;
146
+ log(site, 'revalidated', { hash: hash.slice(0, 12) });
147
+ return json(200, { ok: true, revalidated: true });
148
+ },
149
+ });
150
+ }
151
+ //# sourceMappingURL=google-business-reviews-revalidate.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"google-business-reviews-revalidate.js","sourceRoot":"","sources":["../src/google-business-reviews-revalidate.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n BUSINESS_PROFILE_REVIEWS_HASH_PATTERN,\n BUSINESS_PROFILE_REVIEWS_REVALIDATE_HEADERS,\n BUSINESS_PROFILE_REVIEWS_REVALIDATE_SECRET_NAME,\n businessProfileReviewsSiteTag,\n normaliseBusinessProfileReviewsRevalidateSecret,\n signBusinessProfileReviewsRevalidate,\n} from '@brandlab-digital/site-core/google-business-reviews'\nimport { createBrandlabContactRateLimiter, type BrandlabContactRateLimitRule } from './contact/rate-limit.js'\n\n/**\n * The signed route that refreshes a site's reviews the day they change (3.13.0). Serve it at\n * `src/app/api/brandlab/reviews-revalidate/route.ts`:\n *\n * ```ts\n * import { createBrandlabReviewsRevalidateRoute } from '@brandlab-digital/site-next/google-business-reviews/server'\n *\n * export const { POST } = createBrandlabReviewsRevalidateRoute({ site: 'hr-kitchens' })\n * ```\n *\n * Once the route is deployed with its secret and the site is registered with the daily checker, the site passes\n * `refresh: 'on-change'` to `getBusinessProfileReviews`, which lengthens the fetch's time-based refresh from a day to\n * a 7-day safety net. Until then the fetch stays daily, so a site is never left on weekly reviews by a version bump.\n *\n * The call contract (the daily reviews checker's side):\n * - The checker hashes the feed with site-core `hashBusinessProfileReviews` and keeps, per site, the last hash the route\n * accepted with a 200. It calls only when today's hash differs, so the feed side decides what is a change; the route\n * keeps the last hash in memory too, but only per server instance, so that is a second guard, not the record.\n * - `POST /api/brandlab/reviews-revalidate`, body `{\"site\":\"<slug>\",\"hash\":\"<64 hex>\"}` (at most 1 KB), headers\n * `x-brandlab-timestamp: <Unix seconds>` and `x-brandlab-signature: v1=<hex HMAC-SHA256 of \"<timestamp>.<body>\">`\n * under `BRANDLAB_REVALIDATE_SECRET`. site-core `createBusinessProfileReviewsRevalidateRequest` builds both.\n * - Answers: 200 `{ ok: true, revalidated: true }` after `revalidateTag('brandlab-reviews:<site>', 'max')`; 200\n * `{ ok: true, revalidated: false, reason: 'unchanged' }` when this instance already applied that hash; 400 for a\n * malformed body or another site's slug; 401 for a missing or wrong signature or a timestamp more than 5 minutes\n * off; 413 for a body over 1 KB; 429 with `Retry-After` once one client IP has failed those checks past the rate limit;\n * 503 when the secret is not set; 500 when the refresh itself failed. The checker records the hash only after a 200\n * and retries the rest the next day.\n * - The signature and timestamp are checked before any rate limit, and only failed attempts count, keyed on the\n * platform's `x-real-ip` (never the client-controlled `x-forwarded-for`). A correctly signed request is never\n * refused with 429, and without `x-real-ip` nothing is rate-limited.\n */\n\nexport interface BrandlabReviewsRevalidateRouteOptions {\n /** The site's slug in the reviews feed, the same one `getBusinessProfileReviews` uses. */\n site: string\n /** Defaults to `process.env`, read on each request: `BRANDLAB_REVALIDATE_SECRET` (at least 32 characters). */\n environment?: Readonly<Record<string, string | undefined>>\n /** Defaults to Next's `revalidateTag` from `next/cache`. */\n revalidateTag?: (tag: string, profile: 'max') => void | Promise<void>\n /**\n * Failed attempts (bad signature, stale timestamp, oversized body) per client IP (`x-real-ip`) before the route answers\n * 429, held in memory per server instance (default 10 in 10 minutes); `false` turns it off. Signed requests never count.\n */\n rateLimit?: BrandlabContactRateLimitRule | false\n /** Defaults to `Date.now`. */\n now?: () => number\n}\n\nexport interface BrandlabReviewsRevalidateRoute {\n POST(request: Request): Promise<Response>\n}\n\n/** Failed attempts per client IP before the route answers 429; a correctly signed request is never limited. */\nexport const BRANDLAB_REVIEWS_REVALIDATE_RATE_LIMIT = Object.freeze({ limit: 10, windowMs: 10 * 60 * 1_000 })\n\nconst MAX_BODY_BYTES = 1_024\nconst MAX_CLOCK_SKEW_SECONDS = 300\nconst MIN_SECRET_LENGTH = 32\nconst LOG_EVENT = 'brandlab_reviews_revalidate'\n\nfunction json(status: number, body: Record<string, unknown>, headers: Record<string, string> = {}) {\n return Response.json(body, { status, headers: { 'cache-control': 'no-store', ...headers } })\n}\n\n/** One JSON line per refused or applied request: never the secret, signature or body. */\nfunction log(site: string, result: string, extra: Record<string, unknown> = {}) {\n const line = JSON.stringify({ event: LOG_EVENT, site, result, ...extra })\n if (result === 'revalidated' || result === 'unchanged') console.info(line)\n else console.error(line)\n}\n\n/** Equal-length texts are compared in time that does not depend on where they differ. */\nfunction sameText(a: string, b: string) {\n let difference = a.length ^ b.length\n const reference = a.length === b.length ? b : a\n for (let index = 0; index < a.length; index += 1) difference |= a.charCodeAt(index) ^ reference.charCodeAt(index)\n return difference === 0\n}\n\n/** The platform-set client IP (Vercel's `x-real-ip`); `x-forwarded-for` can be written by the client, so it is not read. */\nfunction clientIp(request: Request) {\n const candidate = request.headers.get('x-real-ip')?.trim()\n return candidate && candidate.length <= 64 && /^[0-9A-Fa-f:.]+$/.test(candidate) ? candidate : undefined\n}\n\n/**\n * The body as text, read at most `limit` bytes into the stream: a larger (or chunked, undeclared) body is cancelled\n * as soon as it passes the limit, never buffered whole. `null` means it was too large.\n */\nasync function readCappedText(request: Request, limit: number): Promise<string | null> {\n if (!request.body) return ''\n const reader = request.body.getReader()\n const chunks: Uint8Array[] = []\n let size = 0\n for (;;) {\n const { done, value } = await reader.read()\n if (done) break\n size += value.byteLength\n if (size > limit) {\n await reader.cancel().catch(() => undefined)\n return null\n }\n chunks.push(value)\n }\n const bytes = new Uint8Array(size)\n let offset = 0\n for (const chunk of chunks) {\n bytes.set(chunk, offset)\n offset += chunk.byteLength\n }\n return new TextDecoder().decode(bytes)\n}\n\nasync function nextRevalidateTag(tag: string, profile: 'max') {\n const { revalidateTag } = await import('next/cache')\n revalidateTag(tag, profile)\n}\n\n/**\n * Creates the signed reviews revalidate route for one site. Create it once at module level, so its rate-limit window\n * and last-applied hash outlive each request.\n */\nexport function createBrandlabReviewsRevalidateRoute(\n options: BrandlabReviewsRevalidateRouteOptions,\n): BrandlabReviewsRevalidateRoute {\n if (!options || typeof options !== 'object') throw new TypeError('Reviews revalidate route options must be an object.')\n const site = options.site\n const tag = businessProfileReviewsSiteTag(site)\n const rule = options.rateLimit === undefined ? BRANDLAB_REVIEWS_REVALIDATE_RATE_LIMIT : options.rateLimit\n const limiter = rule === false ? undefined : createBrandlabContactRateLimiter({ perIp: rule, perAddress: false })\n const revalidate = options.revalidateTag ?? nextRevalidateTag\n const now = options.now ?? Date.now\n let lastHash: string | undefined\n\n return Object.freeze({\n async POST(request: Request): Promise<Response> {\n const at = now()\n // Only failed attempts count towards the limit, so a correctly signed checker is never locked out.\n const refuse = (status: number, error: string, reason: string) => {\n const ip = limiter ? clientIp(request) : undefined\n if (limiter && ip) {\n // Each failed attempt counts once: a fresh id, since the route has no submission id to dedupe on.\n const decision = limiter.check({ ipKey: ip, submissionId: crypto.randomUUID(), now: at })\n if (!decision.ok) {\n log(site, 'refused', { reason: 'rate-limited' })\n return json(429, { ok: false, error: 'rate-limited' }, { 'retry-after': String(decision.retryAfterSeconds) })\n }\n }\n log(site, 'refused', { reason })\n return json(status, { ok: false, error })\n }\n\n const environment = options.environment ?? process.env\n const secret = normaliseBusinessProfileReviewsRevalidateSecret(environment[BUSINESS_PROFILE_REVIEWS_REVALIDATE_SECRET_NAME] ?? '')\n if (!secret || secret.length < MIN_SECRET_LENGTH) {\n log(site, 'refused', { reason: 'not-configured' })\n return json(503, { ok: false, error: 'not-configured' })\n }\n\n const declaredLength = Number(request.headers.get('content-length') ?? '0')\n if (Number.isFinite(declaredLength) && declaredLength > MAX_BODY_BYTES) return refuse(413, 'too-large', 'too-large')\n const body = await readCappedText(request, MAX_BODY_BYTES)\n if (body === null) return refuse(413, 'too-large', 'too-large')\n\n const timestampText = request.headers.get(BUSINESS_PROFILE_REVIEWS_REVALIDATE_HEADERS.timestamp)?.trim() ?? ''\n const signature = request.headers.get(BUSINESS_PROFILE_REVIEWS_REVALIDATE_HEADERS.signature)?.trim() ?? ''\n const timestamp = /^\\d{1,12}$/.test(timestampText) ? Number(timestampText) : Number.NaN\n if (!Number.isSafeInteger(timestamp) || Math.abs(at / 1_000 - timestamp) > MAX_CLOCK_SKEW_SECONDS) {\n return refuse(401, 'unauthorised', 'timestamp')\n }\n const expected = await signBusinessProfileReviewsRevalidate({ secret, body, timestamp })\n if (!sameText(signature, expected)) return refuse(401, 'unauthorised', 'signature')\n\n let parsed: unknown\n try {\n parsed = JSON.parse(body)\n } catch {\n return json(400, { ok: false, error: 'invalid-body' })\n }\n const record = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as Record<string, unknown>) : {}\n if (typeof record.hash !== 'string' || !BUSINESS_PROFILE_REVIEWS_HASH_PATTERN.test(record.hash)) {\n return json(400, { ok: false, error: 'invalid-body' })\n }\n if (record.site !== site) {\n log(site, 'refused', { reason: 'wrong-site' })\n return json(400, { ok: false, error: 'wrong-site' })\n }\n\n const hash = record.hash\n if (hash === lastHash) {\n log(site, 'unchanged', { hash: hash.slice(0, 12) })\n return json(200, { ok: true, revalidated: false, reason: 'unchanged' })\n }\n try {\n await revalidate(tag, 'max')\n } catch (error) {\n log(site, 'failed', { reason: error instanceof Error ? error.name : 'error' })\n return json(500, { ok: false, error: 'revalidate-failed' })\n }\n lastHash = hash\n log(site, 'revalidated', { hash: hash.slice(0, 12) })\n return json(200, { ok: true, revalidated: true })\n },\n })\n}\n"]}
@@ -1,7 +1,22 @@
1
1
  import 'server-only';
2
2
  import { type BusinessProfileReviews } from '@brandlab-digital/site-core/google-business-reviews';
3
- /** Next keeps a successful feed response for a day, so a site asks the feed at most once a day per page. */
3
+ export * from './google-business-reviews-revalidate.js';
4
+ /** By default Next keeps a successful feed response for a day, so a site asks the feed at most once a day per page. */
4
5
  export declare const BUSINESS_PROFILE_REVIEWS_REVALIDATE_SECONDS = 86400;
6
+ /**
7
+ * With `refresh: 'on-change'` (3.13.0) a successful response is kept for 7 days, as a safety net only. Fresh reviews
8
+ * then arrive through the `brandlab-reviews:<site>` tag: the daily reviews checker calls the site's revalidate route
9
+ * when the feed changes, so a new review still shows within a day, and a failed call heals within a week. Seven days
10
+ * is also the shortest refresh check W3 allows an estate page; Next gives a page the shortest revalidate of its
11
+ * fetches, so only an on-change site keeps an estate page that shows reviews within W3.
12
+ */
13
+ export declare const BUSINESS_PROFILE_REVIEWS_ON_CHANGE_REVALIDATE_SECONDS = 604800;
14
+ /**
15
+ * How a site's reviews stay fresh: `daily` (the default) re-reads the feed once a day; `on-change` relies on the
16
+ * signed revalidate route and keeps the weekly safety net. Choose `on-change` only once the route is deployed with
17
+ * `BRANDLAB_REVALIDATE_SECRET` and the site is registered with the daily reviews checker.
18
+ */
19
+ export type BusinessProfileReviewsRefresh = 'daily' | 'on-change';
5
20
  /** The Brandlab reviews feed; `?site=<slug>` names the site. */
6
21
  export declare const BUSINESS_PROFILE_REVIEWS_FEED_ENDPOINT = "https://brandlab-reviews-feed.vercel.app/api/reviews";
7
22
  export declare const BUSINESS_PROFILE_REVIEWS_ENVIRONMENT_NAMES: {
@@ -17,13 +32,17 @@ export interface GetBusinessProfileReviewsOptions {
17
32
  fetcher?: typeof fetch;
18
33
  /** Overrides `REVIEWS_FEED_URL` and the default feed address. Must be https. */
19
34
  feedUrl?: string;
35
+ /** `daily` (default) or `on-change` once the revalidate route and the daily checker are wired (3.13.0). */
36
+ refresh?: BusinessProfileReviewsRefresh;
20
37
  }
21
38
  /**
22
39
  * Google reviews from the Business Profile API, served by the Brandlab reviews feed, for a
23
40
  * server component. Visitors never trigger a Google request and the site holds no Google
24
41
  * credential: only the feed's bearer token. Returns null without `REVIEWS_FEED_TOKEN` and on
25
42
  * any failure (network, timeout after 5 seconds, an error status, an unexpected body), so
26
- * the page shows its no-reviews state. Next caches only a successful response, for a day.
43
+ * the page shows its no-reviews state. Next caches only a successful response, for a day (7 days with
44
+ * `refresh: 'on-change'`), tagged `brandlab-reviews` and `brandlab-reviews:<site>` so the revalidate route can
45
+ * refresh it the day the reviews change.
27
46
  * A `site` that is not lowercase kebab-case is a configuration error and throws.
28
47
  */
29
48
  export declare function getBusinessProfileReviews(options: GetBusinessProfileReviewsOptions): Promise<BusinessProfileReviews | null>;
@@ -1 +1 @@
1
- {"version":3,"file":"google-business-reviews-server.d.ts","sourceRoot":"","sources":["../src/google-business-reviews-server.ts"],"names":[],"mappings":"AAAA,OAAO,aAAa,CAAA;AAEpB,OAAO,EAEL,KAAK,sBAAsB,EAC5B,MAAM,qDAAqD,CAAA;AAE5D,4GAA4G;AAC5G,eAAO,MAAM,2CAA2C,QAAS,CAAA;AAEjE,gEAAgE;AAChE,eAAO,MAAM,sCAAsC,yDAAyD,CAAA;AAE5G,eAAO,MAAM,0CAA0C;;;CAG7C,CAAA;AAKV,MAAM,WAAW,gCAAgC;IAC/C,6FAA6F;IAC7F,IAAI,EAAE,MAAM,CAAA;IACZ,iGAAiG;IACjG,WAAW,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC,CAAA;IAC1D,sCAAsC;IACtC,OAAO,CAAC,EAAE,OAAO,KAAK,CAAA;IACtB,gFAAgF;IAChF,OAAO,CAAC,EAAE,MAAM,CAAA;CACjB;AAgBD;;;;;;;GAOG;AACH,wBAAsB,yBAAyB,CAC7C,OAAO,EAAE,gCAAgC,GACxC,OAAO,CAAC,sBAAsB,GAAG,IAAI,CAAC,CAyBxC"}
1
+ {"version":3,"file":"google-business-reviews-server.d.ts","sourceRoot":"","sources":["../src/google-business-reviews-server.ts"],"names":[],"mappings":"AAAA,OAAO,aAAa,CAAA;AAEpB,OAAO,EAGL,KAAK,sBAAsB,EAC5B,MAAM,qDAAqD,CAAA;AAE5D,cAAc,yCAAyC,CAAA;AAEvD,uHAAuH;AACvH,eAAO,MAAM,2CAA2C,QAAS,CAAA;AAEjE;;;;;;GAMG;AACH,eAAO,MAAM,qDAAqD,SAAU,CAAA;AAE5E;;;;GAIG;AACH,MAAM,MAAM,6BAA6B,GAAG,OAAO,GAAG,WAAW,CAAA;AAEjE,gEAAgE;AAChE,eAAO,MAAM,sCAAsC,yDAAyD,CAAA;AAE5G,eAAO,MAAM,0CAA0C;;;CAG7C,CAAA;AAKV,MAAM,WAAW,gCAAgC;IAC/C,6FAA6F;IAC7F,IAAI,EAAE,MAAM,CAAA;IACZ,iGAAiG;IACjG,WAAW,CAAC,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC,CAAA;IAC1D,sCAAsC;IACtC,OAAO,CAAC,EAAE,OAAO,KAAK,CAAA;IACtB,gFAAgF;IAChF,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,2GAA2G;IAC3G,OAAO,CAAC,EAAE,6BAA6B,CAAA;CACxC;AAgBD;;;;;;;;;GASG;AACH,wBAAsB,yBAAyB,CAC7C,OAAO,EAAE,gCAAgC,GACxC,OAAO,CAAC,sBAAsB,GAAG,IAAI,CAAC,CA+BxC"}
@@ -1,7 +1,16 @@
1
1
  import 'server-only';
2
- import { parseBusinessProfileReviews, } from '@brandlab-digital/site-core/google-business-reviews';
3
- /** Next keeps a successful feed response for a day, so a site asks the feed at most once a day per page. */
2
+ import { businessProfileReviewsTags, parseBusinessProfileReviews, } from '@brandlab-digital/site-core/google-business-reviews';
3
+ export * from './google-business-reviews-revalidate.js';
4
+ /** By default Next keeps a successful feed response for a day, so a site asks the feed at most once a day per page. */
4
5
  export const BUSINESS_PROFILE_REVIEWS_REVALIDATE_SECONDS = 86_400;
6
+ /**
7
+ * With `refresh: 'on-change'` (3.13.0) a successful response is kept for 7 days, as a safety net only. Fresh reviews
8
+ * then arrive through the `brandlab-reviews:<site>` tag: the daily reviews checker calls the site's revalidate route
9
+ * when the feed changes, so a new review still shows within a day, and a failed call heals within a week. Seven days
10
+ * is also the shortest refresh check W3 allows an estate page; Next gives a page the shortest revalidate of its
11
+ * fetches, so only an on-change site keeps an estate page that shows reviews within W3.
12
+ */
13
+ export const BUSINESS_PROFILE_REVIEWS_ON_CHANGE_REVALIDATE_SECONDS = 604_800;
5
14
  /** The Brandlab reviews feed; `?site=<slug>` names the site. */
6
15
  export const BUSINESS_PROFILE_REVIEWS_FEED_ENDPOINT = 'https://brandlab-reviews-feed.vercel.app/api/reviews';
7
16
  export const BUSINESS_PROFILE_REVIEWS_ENVIRONMENT_NAMES = {
@@ -28,13 +37,21 @@ function httpsUrl(value) {
28
37
  * server component. Visitors never trigger a Google request and the site holds no Google
29
38
  * credential: only the feed's bearer token. Returns null without `REVIEWS_FEED_TOKEN` and on
30
39
  * any failure (network, timeout after 5 seconds, an error status, an unexpected body), so
31
- * the page shows its no-reviews state. Next caches only a successful response, for a day.
40
+ * the page shows its no-reviews state. Next caches only a successful response, for a day (7 days with
41
+ * `refresh: 'on-change'`), tagged `brandlab-reviews` and `brandlab-reviews:<site>` so the revalidate route can
42
+ * refresh it the day the reviews change.
32
43
  * A `site` that is not lowercase kebab-case is a configuration error and throws.
33
44
  */
34
45
  export async function getBusinessProfileReviews(options) {
35
46
  if (typeof options.site !== 'string' || !SITE_SLUG.test(options.site)) {
36
47
  throw new Error('Business Profile reviews site must be a lowercase kebab-case slug.');
37
48
  }
49
+ if (options.refresh !== undefined && options.refresh !== 'daily' && options.refresh !== 'on-change') {
50
+ throw new Error("Business Profile reviews refresh must be 'daily' or 'on-change'.");
51
+ }
52
+ const revalidate = options.refresh === 'on-change'
53
+ ? BUSINESS_PROFILE_REVIEWS_ON_CHANGE_REVALIDATE_SECONDS
54
+ : BUSINESS_PROFILE_REVIEWS_REVALIDATE_SECONDS;
38
55
  const environment = options.environment ?? process.env;
39
56
  const token = setting(environment[BUSINESS_PROFILE_REVIEWS_ENVIRONMENT_NAMES.token]);
40
57
  if (!token)
@@ -48,7 +65,7 @@ export async function getBusinessProfileReviews(options) {
48
65
  try {
49
66
  const response = await (options.fetcher ?? fetch)(url, {
50
67
  headers: { authorization: `Bearer ${token}`, accept: 'application/json' },
51
- next: { revalidate: BUSINESS_PROFILE_REVIEWS_REVALIDATE_SECONDS },
68
+ next: { revalidate, tags: businessProfileReviewsTags(options.site) },
52
69
  signal: AbortSignal.timeout(TIMEOUT_MS),
53
70
  });
54
71
  if (!response.ok)
@@ -1 +1 @@
1
- {"version":3,"file":"google-business-reviews-server.js","sourceRoot":"","sources":["../src/google-business-reviews-server.ts"],"names":[],"mappings":"AAAA,OAAO,aAAa,CAAA;AAEpB,OAAO,EACL,2BAA2B,GAE5B,MAAM,qDAAqD,CAAA;AAE5D,4GAA4G;AAC5G,MAAM,CAAC,MAAM,2CAA2C,GAAG,MAAM,CAAA;AAEjE,gEAAgE;AAChE,MAAM,CAAC,MAAM,sCAAsC,GAAG,sDAAsD,CAAA;AAE5G,MAAM,CAAC,MAAM,0CAA0C,GAAG;IACxD,KAAK,EAAE,oBAAoB;IAC3B,OAAO,EAAE,kBAAkB;CACnB,CAAA;AAEV,MAAM,UAAU,GAAG,KAAK,CAAA;AACxB,MAAM,SAAS,GAAG,4BAA4B,CAAA;AAa9C,SAAS,OAAO,CAAC,KAAyB;IACxC,MAAM,OAAO,GAAG,KAAK,EAAE,IAAI,EAAE,CAAA;IAC7B,OAAO,OAAO,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,SAAS,CAAA;AACtC,CAAC;AAED,SAAS,QAAQ,CAAC,KAAa;IAC7B,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,IAAI,GAAG,CAAC,KAAK,CAAC,CAAA;QAC1B,OAAO,GAAG,CAAC,QAAQ,KAAK,QAAQ,CAAC,CAAC,CAAC,GAAG,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC,SAAS,CAAA;IAC/D,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,SAAS,CAAA;IAClB,CAAC;AACH,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,CAAC,KAAK,UAAU,yBAAyB,CAC7C,OAAyC;IAEzC,IAAI,OAAO,OAAO,CAAC,IAAI,KAAK,QAAQ,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,CAAC;QACtE,MAAM,IAAI,KAAK,CAAC,oEAAoE,CAAC,CAAA;IACvF,CAAC;IACD,MAAM,WAAW,GAAG,OAAO,CAAC,WAAW,IAAI,OAAO,CAAC,GAAG,CAAA;IACtD,MAAM,KAAK,GAAG,OAAO,CAAC,WAAW,CAAC,0CAA0C,CAAC,KAAK,CAAC,CAAC,CAAA;IACpF,IAAI,CAAC,KAAK;QAAE,OAAO,IAAI,CAAA;IACvB,kDAAkD;IAClD,MAAM,GAAG,GAAG,QAAQ,CAClB,OAAO,CAAC,OAAO,CAAC,OAAO,CAAC;QACtB,OAAO,CAAC,WAAW,CAAC,0CAA0C,CAAC,OAAO,CAAC,CAAC;QACxE,GAAG,sCAAsC,SAAS,OAAO,CAAC,IAAI,EAAE,CACnE,CAAA;IACD,IAAI,CAAC,GAAG;QAAE,OAAO,IAAI,CAAA;IACrB,IAAI,CAAC;QACH,MAAM,QAAQ,GAAG,MAAM,CAAC,OAAO,CAAC,OAAO,IAAI,KAAK,CAAC,CAAC,GAAG,EAAE;YACrD,OAAO,EAAE,EAAE,aAAa,EAAE,UAAU,KAAK,EAAE,EAAE,MAAM,EAAE,kBAAkB,EAAE;YACzE,IAAI,EAAE,EAAE,UAAU,EAAE,2CAA2C,EAAE;YACjE,MAAM,EAAE,WAAW,CAAC,OAAO,CAAC,UAAU,CAAC;SACzB,CAAC,CAAA;QACjB,IAAI,CAAC,QAAQ,CAAC,EAAE;YAAE,OAAO,IAAI,CAAA;QAC7B,OAAO,2BAA2B,CAAC,MAAM,QAAQ,CAAC,IAAI,EAAE,CAAC,CAAA;IAC3D,CAAC;IAAC,MAAM,CAAC;QACP,OAAO,IAAI,CAAA;IACb,CAAC;AACH,CAAC","sourcesContent":["import 'server-only'\n\nimport {\n parseBusinessProfileReviews,\n type BusinessProfileReviews,\n} from '@brandlab-digital/site-core/google-business-reviews'\n\n/** Next keeps a successful feed response for a day, so a site asks the feed at most once a day per page. */\nexport const BUSINESS_PROFILE_REVIEWS_REVALIDATE_SECONDS = 86_400\n\n/** The Brandlab reviews feed; `?site=<slug>` names the site. */\nexport const BUSINESS_PROFILE_REVIEWS_FEED_ENDPOINT = 'https://brandlab-reviews-feed.vercel.app/api/reviews'\n\nexport const BUSINESS_PROFILE_REVIEWS_ENVIRONMENT_NAMES = {\n token: 'REVIEWS_FEED_TOKEN',\n feedUrl: 'REVIEWS_FEED_URL',\n} as const\n\nconst TIMEOUT_MS = 5_000\nconst SITE_SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/\n\nexport interface GetBusinessProfileReviewsOptions {\n /** The site's slug in the reviews feed (lowercase kebab-case), for example `hr-kitchens`. */\n site: string\n /** Defaults to `process.env`: reads `REVIEWS_FEED_TOKEN` and the optional `REVIEWS_FEED_URL`. */\n environment?: Readonly<Record<string, string | undefined>>\n /** Defaults to the global `fetch`. */\n fetcher?: typeof fetch\n /** Overrides `REVIEWS_FEED_URL` and the default feed address. Must be https. */\n feedUrl?: string\n}\n\nfunction setting(value: string | undefined) {\n const trimmed = value?.trim()\n return trimmed ? trimmed : undefined\n}\n\nfunction httpsUrl(value: string) {\n try {\n const url = new URL(value)\n return url.protocol === 'https:' ? url.toString() : undefined\n } catch {\n return undefined\n }\n}\n\n/**\n * Google reviews from the Business Profile API, served by the Brandlab reviews feed, for a\n * server component. Visitors never trigger a Google request and the site holds no Google\n * credential: only the feed's bearer token. Returns null without `REVIEWS_FEED_TOKEN` and on\n * any failure (network, timeout after 5 seconds, an error status, an unexpected body), so\n * the page shows its no-reviews state. Next caches only a successful response, for a day.\n * A `site` that is not lowercase kebab-case is a configuration error and throws.\n */\nexport async function getBusinessProfileReviews(\n options: GetBusinessProfileReviewsOptions,\n): Promise<BusinessProfileReviews | null> {\n if (typeof options.site !== 'string' || !SITE_SLUG.test(options.site)) {\n throw new Error('Business Profile reviews site must be a lowercase kebab-case slug.')\n }\n const environment = options.environment ?? process.env\n const token = setting(environment[BUSINESS_PROFILE_REVIEWS_ENVIRONMENT_NAMES.token])\n if (!token) return null\n // The bearer token goes only to an https address.\n const url = httpsUrl(\n setting(options.feedUrl) ??\n setting(environment[BUSINESS_PROFILE_REVIEWS_ENVIRONMENT_NAMES.feedUrl]) ??\n `${BUSINESS_PROFILE_REVIEWS_FEED_ENDPOINT}?site=${options.site}`,\n )\n if (!url) return null\n try {\n const response = await (options.fetcher ?? fetch)(url, {\n headers: { authorization: `Bearer ${token}`, accept: 'application/json' },\n next: { revalidate: BUSINESS_PROFILE_REVIEWS_REVALIDATE_SECONDS },\n signal: AbortSignal.timeout(TIMEOUT_MS),\n } as RequestInit)\n if (!response.ok) return null\n return parseBusinessProfileReviews(await response.json())\n } catch {\n return null\n }\n}\n"]}
1
+ {"version":3,"file":"google-business-reviews-server.js","sourceRoot":"","sources":["../src/google-business-reviews-server.ts"],"names":[],"mappings":"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","sourcesContent":["import 'server-only'\n\nimport {\n businessProfileReviewsTags,\n parseBusinessProfileReviews,\n type BusinessProfileReviews,\n} from '@brandlab-digital/site-core/google-business-reviews'\n\nexport * from './google-business-reviews-revalidate.js'\n\n/** By default Next keeps a successful feed response for a day, so a site asks the feed at most once a day per page. */\nexport const BUSINESS_PROFILE_REVIEWS_REVALIDATE_SECONDS = 86_400\n\n/**\n * With `refresh: 'on-change'` (3.13.0) a successful response is kept for 7 days, as a safety net only. Fresh reviews\n * then arrive through the `brandlab-reviews:<site>` tag: the daily reviews checker calls the site's revalidate route\n * when the feed changes, so a new review still shows within a day, and a failed call heals within a week. Seven days\n * is also the shortest refresh check W3 allows an estate page; Next gives a page the shortest revalidate of its\n * fetches, so only an on-change site keeps an estate page that shows reviews within W3.\n */\nexport const BUSINESS_PROFILE_REVIEWS_ON_CHANGE_REVALIDATE_SECONDS = 604_800\n\n/**\n * How a site's reviews stay fresh: `daily` (the default) re-reads the feed once a day; `on-change` relies on the\n * signed revalidate route and keeps the weekly safety net. Choose `on-change` only once the route is deployed with\n * `BRANDLAB_REVALIDATE_SECRET` and the site is registered with the daily reviews checker.\n */\nexport type BusinessProfileReviewsRefresh = 'daily' | 'on-change'\n\n/** The Brandlab reviews feed; `?site=<slug>` names the site. */\nexport const BUSINESS_PROFILE_REVIEWS_FEED_ENDPOINT = 'https://brandlab-reviews-feed.vercel.app/api/reviews'\n\nexport const BUSINESS_PROFILE_REVIEWS_ENVIRONMENT_NAMES = {\n token: 'REVIEWS_FEED_TOKEN',\n feedUrl: 'REVIEWS_FEED_URL',\n} as const\n\nconst TIMEOUT_MS = 5_000\nconst SITE_SLUG = /^[a-z0-9]+(?:-[a-z0-9]+)*$/\n\nexport interface GetBusinessProfileReviewsOptions {\n /** The site's slug in the reviews feed (lowercase kebab-case), for example `hr-kitchens`. */\n site: string\n /** Defaults to `process.env`: reads `REVIEWS_FEED_TOKEN` and the optional `REVIEWS_FEED_URL`. */\n environment?: Readonly<Record<string, string | undefined>>\n /** Defaults to the global `fetch`. */\n fetcher?: typeof fetch\n /** Overrides `REVIEWS_FEED_URL` and the default feed address. Must be https. */\n feedUrl?: string\n /** `daily` (default) or `on-change` once the revalidate route and the daily checker are wired (3.13.0). */\n refresh?: BusinessProfileReviewsRefresh\n}\n\nfunction setting(value: string | undefined) {\n const trimmed = value?.trim()\n return trimmed ? trimmed : undefined\n}\n\nfunction httpsUrl(value: string) {\n try {\n const url = new URL(value)\n return url.protocol === 'https:' ? url.toString() : undefined\n } catch {\n return undefined\n }\n}\n\n/**\n * Google reviews from the Business Profile API, served by the Brandlab reviews feed, for a\n * server component. Visitors never trigger a Google request and the site holds no Google\n * credential: only the feed's bearer token. Returns null without `REVIEWS_FEED_TOKEN` and on\n * any failure (network, timeout after 5 seconds, an error status, an unexpected body), so\n * the page shows its no-reviews state. Next caches only a successful response, for a day (7 days with\n * `refresh: 'on-change'`), tagged `brandlab-reviews` and `brandlab-reviews:<site>` so the revalidate route can\n * refresh it the day the reviews change.\n * A `site` that is not lowercase kebab-case is a configuration error and throws.\n */\nexport async function getBusinessProfileReviews(\n options: GetBusinessProfileReviewsOptions,\n): Promise<BusinessProfileReviews | null> {\n if (typeof options.site !== 'string' || !SITE_SLUG.test(options.site)) {\n throw new Error('Business Profile reviews site must be a lowercase kebab-case slug.')\n }\n if (options.refresh !== undefined && options.refresh !== 'daily' && options.refresh !== 'on-change') {\n throw new Error(\"Business Profile reviews refresh must be 'daily' or 'on-change'.\")\n }\n const revalidate = options.refresh === 'on-change'\n ? BUSINESS_PROFILE_REVIEWS_ON_CHANGE_REVALIDATE_SECONDS\n : BUSINESS_PROFILE_REVIEWS_REVALIDATE_SECONDS\n const environment = options.environment ?? process.env\n const token = setting(environment[BUSINESS_PROFILE_REVIEWS_ENVIRONMENT_NAMES.token])\n if (!token) return null\n // The bearer token goes only to an https address.\n const url = httpsUrl(\n setting(options.feedUrl) ??\n setting(environment[BUSINESS_PROFILE_REVIEWS_ENVIRONMENT_NAMES.feedUrl]) ??\n `${BUSINESS_PROFILE_REVIEWS_FEED_ENDPOINT}?site=${options.site}`,\n )\n if (!url) return null\n try {\n const response = await (options.fetcher ?? fetch)(url, {\n headers: { authorization: `Bearer ${token}`, accept: 'application/json' },\n next: { revalidate, tags: businessProfileReviewsTags(options.site) },\n signal: AbortSignal.timeout(TIMEOUT_MS),\n } as RequestInit)\n if (!response.ok) return null\n return parseBusinessProfileReviews(await response.json())\n } catch {\n return null\n }\n}\n"]}
@@ -1 +1 @@
1
- {"version":3,"file":"headers.d.ts","sourceRoot":"","sources":["../src/headers.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,EAEL,KAAK,kBAAkB,EAExB,MAAM,kCAAkC,CAAA;AAEzC,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;CACd;AAED,MAAM,WAAW,kBAAkB;IACjC,MAAM,EAAE,MAAM,CAAA;IACd,OAAO,EAAE,cAAc,EAAE,CAAA;IACzB,qHAAqH;IACrH,OAAO,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EAAE,CAAA;CAC5C;AAED,eAAO,MAAM,mCAAmC,6CACJ,CAAA;AAE5C,eAAO,MAAM,6BAA6B,kEACuB,CAAA;AAEjE;;;GAGG;AACH,eAAO,MAAM,wCAAwC,YAAY,CAAA;AAUjE;;;;GAIG;AACH,wBAAgB,yBAAyB,CAAC,aAAa,EAAE,MAAM,GAAG,kBAAkB,CAYnF;AAED,eAAO,MAAM,6BAA6B,iCAAiC,CAAA;AAE3E,eAAO,MAAM,8BAA8B,iCAAiC,CAAA;AAE5E,eAAO,MAAM,mBAAmB,qBACZ,CAAA;AAEpB,iFAAiF;AACjF,eAAO,MAAM,mCAAmC,wCAAwC,CAAA;AAExF,MAAM,WAAW,qBAAqB;IACpC,iBAAiB,EAAE,IAAI,CAAA;IACvB,OAAO,CAAC,EAAE,IAAI,CAAA;IACd,mEAAmE;IACnE,iBAAiB,EAAE,MAAM,CAAA;CAC1B;AAoBD,MAAM,WAAW,uCAAuC;IACtD,SAAS,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IAC7B,UAAU,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IAC9B,QAAQ,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;CAC7B;AAED,MAAM,WAAW,6BAA6B;IAC5C,mDAAmD;IACnD,UAAU,EAAE,kBAAkB,CAAA;IAC9B,IAAI,CAAC,EAAE,qBAAqB,CAAA;IAC5B,wEAAwE;IACxE,+BAA+B,CAAC,EAAE,uCAAuC,CAAA;IACzE,2FAA2F;IAC3F,iBAAiB,CAAC,EAAE,MAAM,CAAA;IAC1B,wEAAwE;IACxE,YAAY,CAAC,EAAE,SAAS,cAAc,EAAE,CAAA;CACzC;AAcD,wBAAgB,kCAAkC,CAAC,OAAO,EAAE;IAC1D,UAAU,EAAE,kBAAkB,CAAA;IAC9B,+BAA+B,CAAC,EAAE,uCAAuC,CAAA;CAC1E,UAsDA;AAiDD,wBAAgB,4BAA4B,CAC1C,OAAO,EAAE,6BAA6B,GACrC,cAAc,EAAE,CAmDlB;AAOD,MAAM,WAAW,yBAAyB;IACxC,yDAAyD;IACzD,MAAM,EAAE,MAAM,CAAA;IACd,mEAAmE;IACnE,MAAM,EAAE,SAAS,MAAM,EAAE,CAAA;IACzB;;;;OAIG;IACH,UAAU,EAAE,MAAM,CAAA;CACnB;AAED;;;;;GAKG;AACH,wBAAgB,6BAA6B,CAAC,OAAO,EAAE,OAAO,GAAG,yBAAyB,EAAE,CA4B3F;AAED,MAAM,WAAW,yBAA0B,SAAQ,6BAA6B;IAC9E;;;;;OAKG;IACH,gBAAgB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IACpC;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAA;CACvB;AAED,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,yBAAyB,GAAG,kBAAkB,EAAE,CAyBjG"}
1
+ {"version":3,"file":"headers.d.ts","sourceRoot":"","sources":["../src/headers.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,OAAO,EAEL,KAAK,kBAAkB,EAExB,MAAM,kCAAkC,CAAA;AAEzC,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,MAAM,CAAA;IACX,KAAK,EAAE,MAAM,CAAA;CACd;AAED,MAAM,WAAW,kBAAkB;IACjC,MAAM,EAAE,MAAM,CAAA;IACd,OAAO,EAAE,cAAc,EAAE,CAAA;IACzB,qHAAqH;IACrH,OAAO,CAAC,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,KAAK,EAAE,MAAM,CAAA;KAAE,EAAE,CAAA;CAC5C;AAED,eAAO,MAAM,mCAAmC,6CACJ,CAAA;AAE5C,eAAO,MAAM,6BAA6B,kEACuB,CAAA;AAEjE;;;GAGG;AACH,eAAO,MAAM,wCAAwC,YAAY,CAAA;AAUjE;;;;GAIG;AACH,wBAAgB,yBAAyB,CAAC,aAAa,EAAE,MAAM,GAAG,kBAAkB,CAYnF;AAED,eAAO,MAAM,6BAA6B,iCAAiC,CAAA;AAE3E,eAAO,MAAM,8BAA8B,iCAAiC,CAAA;AAE5E,eAAO,MAAM,mBAAmB,qBACZ,CAAA;AAEpB,iFAAiF;AACjF,eAAO,MAAM,mCAAmC,wCAAwC,CAAA;AAExF,MAAM,WAAW,qBAAqB;IACpC,iBAAiB,EAAE,IAAI,CAAA;IACvB,OAAO,CAAC,EAAE,IAAI,CAAA;IACd,mEAAmE;IACnE,iBAAiB,EAAE,MAAM,CAAA;CAC1B;AA0BD,MAAM,WAAW,uCAAuC;IACtD,SAAS,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IAC7B,UAAU,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IAC9B,QAAQ,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;CAC7B;AAED,MAAM,WAAW,6BAA6B;IAC5C,mDAAmD;IACnD,UAAU,EAAE,kBAAkB,CAAA;IAC9B,IAAI,CAAC,EAAE,qBAAqB,CAAA;IAC5B,wEAAwE;IACxE,+BAA+B,CAAC,EAAE,uCAAuC,CAAA;IACzE,2FAA2F;IAC3F,iBAAiB,CAAC,EAAE,MAAM,CAAA;IAC1B,wEAAwE;IACxE,YAAY,CAAC,EAAE,SAAS,cAAc,EAAE,CAAA;CACzC;AAcD,wBAAgB,kCAAkC,CAAC,OAAO,EAAE;IAC1D,UAAU,EAAE,kBAAkB,CAAA;IAC9B,+BAA+B,CAAC,EAAE,uCAAuC,CAAA;CAC1E,UAsDA;AAiDD,wBAAgB,4BAA4B,CAC1C,OAAO,EAAE,6BAA6B,GACrC,cAAc,EAAE,CAmDlB;AAOD,MAAM,WAAW,yBAAyB;IACxC,yDAAyD;IACzD,MAAM,EAAE,MAAM,CAAA;IACd,mEAAmE;IACnE,MAAM,EAAE,SAAS,MAAM,EAAE,CAAA;IACzB;;;;OAIG;IACH,UAAU,EAAE,MAAM,CAAA;CACnB;AAED;;;;;GAKG;AACH,wBAAgB,6BAA6B,CAAC,OAAO,EAAE,OAAO,GAAG,yBAAyB,EAAE,CA4B3F;AAED,MAAM,WAAW,yBAA0B,SAAQ,6BAA6B;IAC9E;;;;;OAKG;IACH,gBAAgB,CAAC,EAAE,SAAS,MAAM,EAAE,CAAA;IACpC;;;OAGG;IACH,aAAa,CAAC,EAAE,MAAM,CAAA;CACvB;AAED,wBAAgB,wBAAwB,CAAC,OAAO,EAAE,yBAAyB,GAAG,kBAAkB,EAAE,CAyBjG"}
package/dist/headers.js CHANGED
@@ -54,8 +54,14 @@ export const BRANDLAB_HSTS_VALUE = 'max-age=31536000';
54
54
  /** Cache-Control for the declared static public folders on production builds. */
55
55
  export const BRANDLAB_STATIC_ASSET_CACHE_CONTROL = 'public, max-age=31536000, immutable';
56
56
  const HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
57
+ /**
58
+ * Headers a project's `extraHeaders` may not set. The CDN cache headers (3.13.0) sit beside `cache-control`: a
59
+ * hand-set `CDN-Cache-Control` or `Vercel-CDN-Cache-Control` would change what Vercel caches without review.
60
+ */
57
61
  const PROTECTED_HEADER_NAMES = new Set([
58
62
  'cache-control',
63
+ 'cdn-cache-control',
64
+ 'vercel-cdn-cache-control',
59
65
  'content-security-policy',
60
66
  'content-security-policy-report-only',
61
67
  'x-frame-options',
@@ -1 +1 @@
1
- {"version":3,"file":"headers.js","sourceRoot":"","sources":["../src/headers.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Brandlab security/response headers: one canonical header set for every\n * client site, emitted by `defineBrandlabNextConfig`.\n *\n * The deployment decides everything environment-specific:\n * - a production build (`next build`, on Vercel or locally) enforces the\n * production Content Security Policy and sends HSTS;\n * - `next dev` sends a report-only development policy without HSTS;\n * - a Vercel Preview additionally sends `noindex, nofollow, noarchive` and\n * `private, no-store`, so previews never reach search results or caches.\n *\n * The production X-Robots-Tag carries snippet-display controls only; per-page\n * indexation stays owned by Metadata.robots.\n *\n * Declared public folders of static files (`staticAssetCache`) are served with\n * `public, max-age=31536000, immutable` by production builds; Previews and\n * `next dev` never cache them.\n */\n\nimport {\n isBrandlabHostname,\n type BrandlabDeployment,\n type BrandlabDeploymentEnvironment,\n} from '@brandlab-digital/site-core/site'\n\nexport interface BrandlabHeader {\n key: string\n value: string\n}\n\nexport interface BrandlabHeaderRule {\n source: string\n headers: BrandlabHeader[]\n /** The rule applies only when none of these match (Next `missing`: host values are anchored regular expressions). */\n missing?: { type: 'host'; value: string }[]\n}\n\nexport const BRANDLAB_DEFAULT_PERMISSIONS_POLICY =\n 'camera=(), microphone=(), geolocation=()'\n\nexport const BRANDLAB_DEFAULT_X_ROBOTS_TAG =\n 'max-snippet:-1, max-image-preview:large, max-video-preview:-1'\n\n/**\n * What a production deployment answers on any host but its canonical one (3.2.1): the `<project>.vercel.app` alias\n * and the deployment's own URLs serve the full site, so they tell crawlers not to index it. Alias hosts redirect.\n */\nexport const BRANDLAB_NON_CANONICAL_HOST_X_ROBOTS_TAG = 'noindex'\n\n/** Host patterns a production build answers normally besides its canonical host: a local `next start` (CI, the harness). */\nconst LOCAL_HOST_PATTERNS = ['localhost', '127\\\\.0\\\\.0\\\\.1']\n\n/** A host as an exact pattern (Next anchors `has`/`missing` values as regular expressions). */\nfunction exactHostPattern(host: string): string {\n return host.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n}\n\n/**\n * Production deployments answer every host but the canonical one (and a local server's) with\n * `X-Robots-Tag: noindex`: the `*.vercel.app` production alias and deployment URLs serve the full site. The rule\n * comes last, so it overrides the default X-Robots-Tag there; the canonical host keeps the default.\n */\nexport function buildNonCanonicalHostRule(canonicalHost: string): BrandlabHeaderRule {\n // Next compares a request's host lowercased and without its port: a host in any other form would match no request,\n // and every host, the canonical one included, would be told noindex.\n const host = typeof canonicalHost === 'string' ? canonicalHost.toLowerCase() : canonicalHost\n if (!isBrandlabHostname(host)) {\n throw new Error(`The canonical host must be a bare hostname (no scheme, port, path or trailing dot): \"${String(canonicalHost)}\".`)\n }\n return {\n source: '/:path*',\n missing: [exactHostPattern(host), ...LOCAL_HOST_PATTERNS].map((value) => ({ type: 'host' as const, value })),\n headers: [{ key: 'X-Robots-Tag', value: BRANDLAB_NON_CANONICAL_HOST_X_ROBOTS_TAG }],\n }\n}\n\nexport const BRANDLAB_PREVIEW_X_ROBOTS_TAG = 'noindex, nofollow, noarchive'\n\nexport const BRANDLAB_PREVIEW_CACHE_CONTROL = 'private, no-store, max-age=0'\n\nexport const BRANDLAB_HSTS_VALUE =\n 'max-age=31536000'\n\n/** Cache-Control for the declared static public folders on production builds. */\nexport const BRANDLAB_STATIC_ASSET_CACHE_CONTROL = 'public, max-age=31536000, immutable'\n\nexport interface BrandlabHstsExpansion {\n includeSubDomains: true\n preload?: true\n /** Reviewed proof that every affected subdomain is HTTPS-ready. */\n readinessEvidence: string\n}\n\nconst HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/\nconst PROTECTED_HEADER_NAMES = new Set([\n 'cache-control',\n 'content-security-policy',\n 'content-security-policy-report-only',\n 'x-frame-options',\n 'x-content-type-options',\n 'referrer-policy',\n 'permissions-policy',\n 'x-robots-tag',\n 'strict-transport-security',\n])\nconst DEPLOYMENT_ENVIRONMENTS = new Set<BrandlabDeploymentEnvironment>([\n 'production',\n 'preview',\n 'development',\n])\n\nexport interface BrandlabContentSecurityPolicyExtensions {\n scriptSrc?: readonly string[]\n connectSrc?: readonly string[]\n frameSrc?: readonly string[]\n}\n\nexport interface BrandlabSecurityHeaderOptions {\n /** From resolveBrandlabDeployment(process.env). */\n deployment: BrandlabDeployment\n hsts?: BrandlabHstsExpansion\n /** Reviewed HTTPS origins needed by an approved project integration. */\n contentSecurityPolicyExtensions?: BrandlabContentSecurityPolicyExtensions\n /** Reviewed project exception, for example `geolocation=(self)` for a location feature. */\n permissionsPolicy?: string\n /** Reviewed project headers; they cannot replace a protected header. */\n extraHeaders?: readonly BrandlabHeader[]\n}\n\nfunction assertDeployment(deployment: unknown): asserts deployment is BrandlabDeployment {\n const candidate = deployment as Partial<BrandlabDeployment> | undefined\n if (\n !candidate ||\n typeof candidate !== 'object' ||\n !DEPLOYMENT_ENVIRONMENTS.has(candidate.environment as BrandlabDeploymentEnvironment) ||\n typeof candidate.productionBuild !== 'boolean'\n ) {\n throw new Error('Header rules require the deployment from resolveBrandlabDeployment(process.env).')\n }\n}\n\nexport function buildBrandlabContentSecurityPolicy(options: {\n deployment: BrandlabDeployment\n contentSecurityPolicyExtensions?: BrandlabContentSecurityPolicyExtensions\n}) {\n assertDeployment(options.deployment)\n const productionBuild = options.deployment.productionBuild\n\n const scriptSources = [\n \"'self'\",\n \"'unsafe-inline'\",\n 'https://www.googletagmanager.com',\n 'https://www.googleadservices.com',\n 'https://www.google.com',\n 'https://www.gstatic.com',\n 'https://pagead2.googlesyndication.com',\n 'https://googleads.g.doubleclick.net',\n 'https://challenges.cloudflare.com',\n ...normalizeCspExtensions(options.contentSecurityPolicyExtensions?.scriptSrc, 'scriptSrc'),\n ...(productionBuild ? [] : [\"'unsafe-eval'\", 'http://localhost:*']),\n ]\n const connectSources = [\n \"'self'\",\n 'https://www.google-analytics.com',\n 'https://*.google-analytics.com',\n 'https://www.googletagmanager.com',\n 'https://www.googleadservices.com',\n 'https://*.doubleclick.net',\n 'https://*.google.com',\n 'https://google.com',\n 'https://www.google.com.au',\n 'https://pagead2.googlesyndication.com',\n 'https://*.googletagmanager.com',\n 'https://*.analytics.google.com',\n 'https://challenges.cloudflare.com',\n ...normalizeCspExtensions(options.contentSecurityPolicyExtensions?.connectSrc, 'connectSrc'),\n ...(productionBuild ? [] : ['http://localhost:*', 'ws://localhost:*']),\n ]\n const directives = [\n \"default-src 'self'\",\n \"base-uri 'self'\",\n \"object-src 'none'\",\n \"frame-ancestors 'none'\",\n \"form-action 'self'\",\n `script-src ${scriptSources.join(' ')}`,\n \"style-src 'self' 'unsafe-inline'\",\n \"font-src 'self' data:\",\n \"img-src 'self' data: blob: https:\",\n `connect-src ${connectSources.join(' ')}`,\n `frame-src 'self' https://challenges.cloudflare.com https://www.googletagmanager.com https://www.google.com ${normalizeCspExtensions(\n options.contentSecurityPolicyExtensions?.frameSrc,\n 'frameSrc',\n ).join(' ')}`.trim(),\n \"manifest-src 'self'\",\n \"worker-src 'self' blob:\",\n ...(productionBuild ? ['upgrade-insecure-requests'] : []),\n ]\n return directives.join('; ')\n}\n\nfunction normalizeCspExtensions(\n sources: readonly string[] | undefined,\n field: keyof BrandlabContentSecurityPolicyExtensions,\n) {\n if (!sources) return []\n const normalized = sources.map((source) => {\n const value = source.trim().toLowerCase()\n if (!/^https:\\/\\/(?:\\*\\.)?[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?(?::\\d{1,5})?$/.test(value)) {\n throw new Error(`${field} entries must be HTTPS origins without paths or CSP keywords.`)\n }\n return value\n })\n if (new Set(normalized).size !== normalized.length) {\n throw new Error(`${field} entries must be unique.`)\n }\n return normalized\n}\n\nfunction assertHeaderValue(value: string, field: string) {\n if (!value || value.length > 4_096 || /[\\u0000-\\u001F\\u007F]/.test(value)) {\n throw new Error(`${field} must be a bounded single-line header value.`)\n }\n}\n\nfunction validateExtraHeaders(headers: readonly BrandlabHeader[]) {\n const names = new Set<string>()\n headers.forEach((header, index) => {\n if (!HEADER_NAME_PATTERN.test(header.key)) {\n throw new Error(`extraHeaders[${index}].key is invalid.`)\n }\n const name = header.key.toLowerCase()\n if (PROTECTED_HEADER_NAMES.has(name) || names.has(name)) {\n throw new Error(`extraHeaders[${index}] duplicates a protected or existing header.`)\n }\n names.add(name)\n assertHeaderValue(header.value, `extraHeaders[${index}].value`)\n })\n}\n\nconst HEADER_OPTION_KEYS = new Set([\n 'deployment',\n 'hsts',\n 'contentSecurityPolicyExtensions',\n 'permissionsPolicy',\n 'extraHeaders',\n])\n\nexport function buildBrandlabSecurityHeaders(\n options: BrandlabSecurityHeaderOptions,\n): BrandlabHeader[] {\n if (!options || typeof options !== 'object') {\n throw new TypeError('Header options are required.')\n }\n const unsupported = Object.keys(options).filter((key) => !HEADER_OPTION_KEYS.has(key))\n if (unsupported.length > 0) {\n throw new Error(\n `Header options contain unsupported keys: ${unsupported.join(', ')}. The deployment decides CSP, HSTS and X-Robots-Tag.`,\n )\n }\n assertDeployment(options.deployment)\n const { environment, productionBuild } = options.deployment\n const permissionsPolicy =\n options.permissionsPolicy ?? BRANDLAB_DEFAULT_PERMISSIONS_POLICY\n assertHeaderValue(permissionsPolicy, 'permissionsPolicy')\n validateExtraHeaders(options.extraHeaders ?? [])\n if (options.hsts) {\n assertHeaderValue(options.hsts.readinessEvidence, 'hsts.readinessEvidence')\n if (options.hsts.preload && !options.hsts.includeSubDomains) {\n throw new Error('HSTS preload requires includeSubDomains readiness.')\n }\n }\n const hstsValue = options.hsts\n ? `${BRANDLAB_HSTS_VALUE}; includeSubDomains${options.hsts.preload ? '; preload' : ''}`\n : BRANDLAB_HSTS_VALUE\n const preview = environment === 'preview'\n\n return [\n { key: 'X-Frame-Options', value: 'DENY' },\n { key: 'X-Content-Type-Options', value: 'nosniff' },\n { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },\n {\n key: 'Permissions-Policy',\n value: permissionsPolicy,\n },\n {\n key: 'X-Robots-Tag',\n value: preview ? BRANDLAB_PREVIEW_X_ROBOTS_TAG : BRANDLAB_DEFAULT_X_ROBOTS_TAG,\n },\n ...(productionBuild\n ? [{ key: 'Strict-Transport-Security', value: hstsValue }]\n : []),\n {\n key: productionBuild\n ? 'Content-Security-Policy'\n : 'Content-Security-Policy-Report-Only',\n value: buildBrandlabContentSecurityPolicy(options),\n },\n ...(options.extraHeaders ?? []).map((header) => ({ ...header })),\n ...(preview ? [{ key: 'Cache-Control', value: BRANDLAB_PREVIEW_CACHE_CONTROL }] : []),\n ]\n}\n\nconst STATIC_ASSET_SOURCE = /^\\/((?:[A-Za-z0-9][A-Za-z0-9._-]*\\/){1,4}):([A-Za-z_][A-Za-z0-9_]*)\\*$/\nconst STATIC_ASSET_RESERVED = new Set(['api', '_next', '_vercel'])\nconst STATIC_ASSET_MAX_SOURCES = 10\nconst STATIC_ASSET_MAX_LENGTH = 100\n\nexport interface BrandlabStaticAssetFolder {\n /** The declared source, for example `/images/:path*`. */\n source: string\n /** The public folder's path segments, for example `['images']`. */\n folder: readonly string[]\n /**\n * The emitted rule's source: file names with an extension below the folder,\n * such as `/images/:path(.+\\.[A-Za-z0-9]+)`. The folder itself and\n * extensionless paths, where a page would answer, never match.\n */\n fileSource: string\n}\n\n/**\n * Validates `staticAssetCache`: at most 10 public folders, each written as Next's\n * source `/<folder>/:path*` (one to four literal segments, then one catch-all\n * parameter, at most 100 characters). `/`, `/api`, `/_next` and `/_vercel` are\n * rejected.\n */\nexport function parseBrandlabStaticAssetCache(sources: unknown): BrandlabStaticAssetFolder[] {\n if (!Array.isArray(sources)) {\n throw new Error('staticAssetCache must list public folders such as /images/:path*.')\n }\n if (sources.length > STATIC_ASSET_MAX_SOURCES) {\n throw new Error(`staticAssetCache lists at most ${STATIC_ASSET_MAX_SOURCES} public folders.`)\n }\n const seen = new Set<string>()\n return sources.map((source: unknown, index) => {\n const field = `staticAssetCache[${index}]`\n if (typeof source !== 'string' || source.length > STATIC_ASSET_MAX_LENGTH) {\n throw new Error(`${field} must be a source of at most ${STATIC_ASSET_MAX_LENGTH} characters.`)\n }\n if (STATIC_ASSET_RESERVED.has(source.split('/')[1]?.toLowerCase() ?? '')) {\n throw new Error(`${field} \"${source}\" is a Next or API path, not a public folder.`)\n }\n const match = STATIC_ASSET_SOURCE.exec(source)\n if (!match) {\n throw new Error(\n `${field} \"${source}\" must name a public folder as /<folder>/:path* (literal folder segments, then one catch-all parameter).`,\n )\n }\n const folder = match[1]!.slice(0, -1).split('/')\n const key = folder.join('/').toLowerCase()\n if (seen.has(key)) throw new Error(`${field} repeats the /${folder.join('/')} folder.`)\n seen.add(key)\n return { source, folder, fileSource: `/${folder.join('/')}/:${match[2]}(.+\\\\.[A-Za-z0-9]+)` }\n })\n}\n\nexport interface BrandlabHeaderRuleOptions extends BrandlabSecurityHeaderOptions {\n /**\n * Public folders of static files that never change in place, for example\n * `['/images/:path*', '/fonts/:path*']`: production builds serve their files with\n * `public, max-age=31536000, immutable`. A file replaced under the same name stays\n * cached in browsers for up to a year, so give a changed file a new name.\n */\n staticAssetCache?: readonly string[]\n /**\n * The canonical host (3.2.1): a production deployment answers every other host (the `*.vercel.app` alias, the\n * deployment URLs) with `X-Robots-Tag: noindex`.\n */\n canonicalHost?: string\n}\n\nexport function buildBrandlabHeaderRules(options: BrandlabHeaderRuleOptions): BrandlabHeaderRule[] {\n if (!options || typeof options !== 'object') {\n throw new TypeError('Header options are required.')\n }\n const { staticAssetCache, canonicalHost, ...security } = options\n const folders = staticAssetCache === undefined ? [] : parseBrandlabStaticAssetCache(staticAssetCache)\n const rules: BrandlabHeaderRule[] = [\n {\n source: '/:path*',\n headers: buildBrandlabSecurityHeaders(security),\n },\n ]\n // A production build outside a Preview; Previews keep private, no-store everywhere.\n const { environment, productionBuild } = options.deployment\n if (productionBuild && environment !== 'preview') {\n for (const folder of folders) {\n rules.push({\n source: folder.fileSource,\n headers: [{ key: 'Cache-Control', value: BRANDLAB_STATIC_ASSET_CACHE_CONTROL }],\n })\n }\n }\n // Only a real production deployment: Previews already send noindex everywhere, and development has no crawlers.\n if (environment === 'production' && canonicalHost) rules.push(buildNonCanonicalHostRule(canonicalHost))\n return rules\n}\n"]}
1
+ {"version":3,"file":"headers.js","sourceRoot":"","sources":["../src/headers.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Brandlab security/response headers: one canonical header set for every\n * client site, emitted by `defineBrandlabNextConfig`.\n *\n * The deployment decides everything environment-specific:\n * - a production build (`next build`, on Vercel or locally) enforces the\n * production Content Security Policy and sends HSTS;\n * - `next dev` sends a report-only development policy without HSTS;\n * - a Vercel Preview additionally sends `noindex, nofollow, noarchive` and\n * `private, no-store`, so previews never reach search results or caches.\n *\n * The production X-Robots-Tag carries snippet-display controls only; per-page\n * indexation stays owned by Metadata.robots.\n *\n * Declared public folders of static files (`staticAssetCache`) are served with\n * `public, max-age=31536000, immutable` by production builds; Previews and\n * `next dev` never cache them.\n */\n\nimport {\n isBrandlabHostname,\n type BrandlabDeployment,\n type BrandlabDeploymentEnvironment,\n} from '@brandlab-digital/site-core/site'\n\nexport interface BrandlabHeader {\n key: string\n value: string\n}\n\nexport interface BrandlabHeaderRule {\n source: string\n headers: BrandlabHeader[]\n /** The rule applies only when none of these match (Next `missing`: host values are anchored regular expressions). */\n missing?: { type: 'host'; value: string }[]\n}\n\nexport const BRANDLAB_DEFAULT_PERMISSIONS_POLICY =\n 'camera=(), microphone=(), geolocation=()'\n\nexport const BRANDLAB_DEFAULT_X_ROBOTS_TAG =\n 'max-snippet:-1, max-image-preview:large, max-video-preview:-1'\n\n/**\n * What a production deployment answers on any host but its canonical one (3.2.1): the `<project>.vercel.app` alias\n * and the deployment's own URLs serve the full site, so they tell crawlers not to index it. Alias hosts redirect.\n */\nexport const BRANDLAB_NON_CANONICAL_HOST_X_ROBOTS_TAG = 'noindex'\n\n/** Host patterns a production build answers normally besides its canonical host: a local `next start` (CI, the harness). */\nconst LOCAL_HOST_PATTERNS = ['localhost', '127\\\\.0\\\\.0\\\\.1']\n\n/** A host as an exact pattern (Next anchors `has`/`missing` values as regular expressions). */\nfunction exactHostPattern(host: string): string {\n return host.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&')\n}\n\n/**\n * Production deployments answer every host but the canonical one (and a local server's) with\n * `X-Robots-Tag: noindex`: the `*.vercel.app` production alias and deployment URLs serve the full site. The rule\n * comes last, so it overrides the default X-Robots-Tag there; the canonical host keeps the default.\n */\nexport function buildNonCanonicalHostRule(canonicalHost: string): BrandlabHeaderRule {\n // Next compares a request's host lowercased and without its port: a host in any other form would match no request,\n // and every host, the canonical one included, would be told noindex.\n const host = typeof canonicalHost === 'string' ? canonicalHost.toLowerCase() : canonicalHost\n if (!isBrandlabHostname(host)) {\n throw new Error(`The canonical host must be a bare hostname (no scheme, port, path or trailing dot): \"${String(canonicalHost)}\".`)\n }\n return {\n source: '/:path*',\n missing: [exactHostPattern(host), ...LOCAL_HOST_PATTERNS].map((value) => ({ type: 'host' as const, value })),\n headers: [{ key: 'X-Robots-Tag', value: BRANDLAB_NON_CANONICAL_HOST_X_ROBOTS_TAG }],\n }\n}\n\nexport const BRANDLAB_PREVIEW_X_ROBOTS_TAG = 'noindex, nofollow, noarchive'\n\nexport const BRANDLAB_PREVIEW_CACHE_CONTROL = 'private, no-store, max-age=0'\n\nexport const BRANDLAB_HSTS_VALUE =\n 'max-age=31536000'\n\n/** Cache-Control for the declared static public folders on production builds. */\nexport const BRANDLAB_STATIC_ASSET_CACHE_CONTROL = 'public, max-age=31536000, immutable'\n\nexport interface BrandlabHstsExpansion {\n includeSubDomains: true\n preload?: true\n /** Reviewed proof that every affected subdomain is HTTPS-ready. */\n readinessEvidence: string\n}\n\nconst HEADER_NAME_PATTERN = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/\n/**\n * Headers a project's `extraHeaders` may not set. The CDN cache headers (3.13.0) sit beside `cache-control`: a\n * hand-set `CDN-Cache-Control` or `Vercel-CDN-Cache-Control` would change what Vercel caches without review.\n */\nconst PROTECTED_HEADER_NAMES = new Set([\n 'cache-control',\n 'cdn-cache-control',\n 'vercel-cdn-cache-control',\n 'content-security-policy',\n 'content-security-policy-report-only',\n 'x-frame-options',\n 'x-content-type-options',\n 'referrer-policy',\n 'permissions-policy',\n 'x-robots-tag',\n 'strict-transport-security',\n])\nconst DEPLOYMENT_ENVIRONMENTS = new Set<BrandlabDeploymentEnvironment>([\n 'production',\n 'preview',\n 'development',\n])\n\nexport interface BrandlabContentSecurityPolicyExtensions {\n scriptSrc?: readonly string[]\n connectSrc?: readonly string[]\n frameSrc?: readonly string[]\n}\n\nexport interface BrandlabSecurityHeaderOptions {\n /** From resolveBrandlabDeployment(process.env). */\n deployment: BrandlabDeployment\n hsts?: BrandlabHstsExpansion\n /** Reviewed HTTPS origins needed by an approved project integration. */\n contentSecurityPolicyExtensions?: BrandlabContentSecurityPolicyExtensions\n /** Reviewed project exception, for example `geolocation=(self)` for a location feature. */\n permissionsPolicy?: string\n /** Reviewed project headers; they cannot replace a protected header. */\n extraHeaders?: readonly BrandlabHeader[]\n}\n\nfunction assertDeployment(deployment: unknown): asserts deployment is BrandlabDeployment {\n const candidate = deployment as Partial<BrandlabDeployment> | undefined\n if (\n !candidate ||\n typeof candidate !== 'object' ||\n !DEPLOYMENT_ENVIRONMENTS.has(candidate.environment as BrandlabDeploymentEnvironment) ||\n typeof candidate.productionBuild !== 'boolean'\n ) {\n throw new Error('Header rules require the deployment from resolveBrandlabDeployment(process.env).')\n }\n}\n\nexport function buildBrandlabContentSecurityPolicy(options: {\n deployment: BrandlabDeployment\n contentSecurityPolicyExtensions?: BrandlabContentSecurityPolicyExtensions\n}) {\n assertDeployment(options.deployment)\n const productionBuild = options.deployment.productionBuild\n\n const scriptSources = [\n \"'self'\",\n \"'unsafe-inline'\",\n 'https://www.googletagmanager.com',\n 'https://www.googleadservices.com',\n 'https://www.google.com',\n 'https://www.gstatic.com',\n 'https://pagead2.googlesyndication.com',\n 'https://googleads.g.doubleclick.net',\n 'https://challenges.cloudflare.com',\n ...normalizeCspExtensions(options.contentSecurityPolicyExtensions?.scriptSrc, 'scriptSrc'),\n ...(productionBuild ? [] : [\"'unsafe-eval'\", 'http://localhost:*']),\n ]\n const connectSources = [\n \"'self'\",\n 'https://www.google-analytics.com',\n 'https://*.google-analytics.com',\n 'https://www.googletagmanager.com',\n 'https://www.googleadservices.com',\n 'https://*.doubleclick.net',\n 'https://*.google.com',\n 'https://google.com',\n 'https://www.google.com.au',\n 'https://pagead2.googlesyndication.com',\n 'https://*.googletagmanager.com',\n 'https://*.analytics.google.com',\n 'https://challenges.cloudflare.com',\n ...normalizeCspExtensions(options.contentSecurityPolicyExtensions?.connectSrc, 'connectSrc'),\n ...(productionBuild ? [] : ['http://localhost:*', 'ws://localhost:*']),\n ]\n const directives = [\n \"default-src 'self'\",\n \"base-uri 'self'\",\n \"object-src 'none'\",\n \"frame-ancestors 'none'\",\n \"form-action 'self'\",\n `script-src ${scriptSources.join(' ')}`,\n \"style-src 'self' 'unsafe-inline'\",\n \"font-src 'self' data:\",\n \"img-src 'self' data: blob: https:\",\n `connect-src ${connectSources.join(' ')}`,\n `frame-src 'self' https://challenges.cloudflare.com https://www.googletagmanager.com https://www.google.com ${normalizeCspExtensions(\n options.contentSecurityPolicyExtensions?.frameSrc,\n 'frameSrc',\n ).join(' ')}`.trim(),\n \"manifest-src 'self'\",\n \"worker-src 'self' blob:\",\n ...(productionBuild ? ['upgrade-insecure-requests'] : []),\n ]\n return directives.join('; ')\n}\n\nfunction normalizeCspExtensions(\n sources: readonly string[] | undefined,\n field: keyof BrandlabContentSecurityPolicyExtensions,\n) {\n if (!sources) return []\n const normalized = sources.map((source) => {\n const value = source.trim().toLowerCase()\n if (!/^https:\\/\\/(?:\\*\\.)?[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?(?::\\d{1,5})?$/.test(value)) {\n throw new Error(`${field} entries must be HTTPS origins without paths or CSP keywords.`)\n }\n return value\n })\n if (new Set(normalized).size !== normalized.length) {\n throw new Error(`${field} entries must be unique.`)\n }\n return normalized\n}\n\nfunction assertHeaderValue(value: string, field: string) {\n if (!value || value.length > 4_096 || /[\\u0000-\\u001F\\u007F]/.test(value)) {\n throw new Error(`${field} must be a bounded single-line header value.`)\n }\n}\n\nfunction validateExtraHeaders(headers: readonly BrandlabHeader[]) {\n const names = new Set<string>()\n headers.forEach((header, index) => {\n if (!HEADER_NAME_PATTERN.test(header.key)) {\n throw new Error(`extraHeaders[${index}].key is invalid.`)\n }\n const name = header.key.toLowerCase()\n if (PROTECTED_HEADER_NAMES.has(name) || names.has(name)) {\n throw new Error(`extraHeaders[${index}] duplicates a protected or existing header.`)\n }\n names.add(name)\n assertHeaderValue(header.value, `extraHeaders[${index}].value`)\n })\n}\n\nconst HEADER_OPTION_KEYS = new Set([\n 'deployment',\n 'hsts',\n 'contentSecurityPolicyExtensions',\n 'permissionsPolicy',\n 'extraHeaders',\n])\n\nexport function buildBrandlabSecurityHeaders(\n options: BrandlabSecurityHeaderOptions,\n): BrandlabHeader[] {\n if (!options || typeof options !== 'object') {\n throw new TypeError('Header options are required.')\n }\n const unsupported = Object.keys(options).filter((key) => !HEADER_OPTION_KEYS.has(key))\n if (unsupported.length > 0) {\n throw new Error(\n `Header options contain unsupported keys: ${unsupported.join(', ')}. The deployment decides CSP, HSTS and X-Robots-Tag.`,\n )\n }\n assertDeployment(options.deployment)\n const { environment, productionBuild } = options.deployment\n const permissionsPolicy =\n options.permissionsPolicy ?? BRANDLAB_DEFAULT_PERMISSIONS_POLICY\n assertHeaderValue(permissionsPolicy, 'permissionsPolicy')\n validateExtraHeaders(options.extraHeaders ?? [])\n if (options.hsts) {\n assertHeaderValue(options.hsts.readinessEvidence, 'hsts.readinessEvidence')\n if (options.hsts.preload && !options.hsts.includeSubDomains) {\n throw new Error('HSTS preload requires includeSubDomains readiness.')\n }\n }\n const hstsValue = options.hsts\n ? `${BRANDLAB_HSTS_VALUE}; includeSubDomains${options.hsts.preload ? '; preload' : ''}`\n : BRANDLAB_HSTS_VALUE\n const preview = environment === 'preview'\n\n return [\n { key: 'X-Frame-Options', value: 'DENY' },\n { key: 'X-Content-Type-Options', value: 'nosniff' },\n { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },\n {\n key: 'Permissions-Policy',\n value: permissionsPolicy,\n },\n {\n key: 'X-Robots-Tag',\n value: preview ? BRANDLAB_PREVIEW_X_ROBOTS_TAG : BRANDLAB_DEFAULT_X_ROBOTS_TAG,\n },\n ...(productionBuild\n ? [{ key: 'Strict-Transport-Security', value: hstsValue }]\n : []),\n {\n key: productionBuild\n ? 'Content-Security-Policy'\n : 'Content-Security-Policy-Report-Only',\n value: buildBrandlabContentSecurityPolicy(options),\n },\n ...(options.extraHeaders ?? []).map((header) => ({ ...header })),\n ...(preview ? [{ key: 'Cache-Control', value: BRANDLAB_PREVIEW_CACHE_CONTROL }] : []),\n ]\n}\n\nconst STATIC_ASSET_SOURCE = /^\\/((?:[A-Za-z0-9][A-Za-z0-9._-]*\\/){1,4}):([A-Za-z_][A-Za-z0-9_]*)\\*$/\nconst STATIC_ASSET_RESERVED = new Set(['api', '_next', '_vercel'])\nconst STATIC_ASSET_MAX_SOURCES = 10\nconst STATIC_ASSET_MAX_LENGTH = 100\n\nexport interface BrandlabStaticAssetFolder {\n /** The declared source, for example `/images/:path*`. */\n source: string\n /** The public folder's path segments, for example `['images']`. */\n folder: readonly string[]\n /**\n * The emitted rule's source: file names with an extension below the folder,\n * such as `/images/:path(.+\\.[A-Za-z0-9]+)`. The folder itself and\n * extensionless paths, where a page would answer, never match.\n */\n fileSource: string\n}\n\n/**\n * Validates `staticAssetCache`: at most 10 public folders, each written as Next's\n * source `/<folder>/:path*` (one to four literal segments, then one catch-all\n * parameter, at most 100 characters). `/`, `/api`, `/_next` and `/_vercel` are\n * rejected.\n */\nexport function parseBrandlabStaticAssetCache(sources: unknown): BrandlabStaticAssetFolder[] {\n if (!Array.isArray(sources)) {\n throw new Error('staticAssetCache must list public folders such as /images/:path*.')\n }\n if (sources.length > STATIC_ASSET_MAX_SOURCES) {\n throw new Error(`staticAssetCache lists at most ${STATIC_ASSET_MAX_SOURCES} public folders.`)\n }\n const seen = new Set<string>()\n return sources.map((source: unknown, index) => {\n const field = `staticAssetCache[${index}]`\n if (typeof source !== 'string' || source.length > STATIC_ASSET_MAX_LENGTH) {\n throw new Error(`${field} must be a source of at most ${STATIC_ASSET_MAX_LENGTH} characters.`)\n }\n if (STATIC_ASSET_RESERVED.has(source.split('/')[1]?.toLowerCase() ?? '')) {\n throw new Error(`${field} \"${source}\" is a Next or API path, not a public folder.`)\n }\n const match = STATIC_ASSET_SOURCE.exec(source)\n if (!match) {\n throw new Error(\n `${field} \"${source}\" must name a public folder as /<folder>/:path* (literal folder segments, then one catch-all parameter).`,\n )\n }\n const folder = match[1]!.slice(0, -1).split('/')\n const key = folder.join('/').toLowerCase()\n if (seen.has(key)) throw new Error(`${field} repeats the /${folder.join('/')} folder.`)\n seen.add(key)\n return { source, folder, fileSource: `/${folder.join('/')}/:${match[2]}(.+\\\\.[A-Za-z0-9]+)` }\n })\n}\n\nexport interface BrandlabHeaderRuleOptions extends BrandlabSecurityHeaderOptions {\n /**\n * Public folders of static files that never change in place, for example\n * `['/images/:path*', '/fonts/:path*']`: production builds serve their files with\n * `public, max-age=31536000, immutable`. A file replaced under the same name stays\n * cached in browsers for up to a year, so give a changed file a new name.\n */\n staticAssetCache?: readonly string[]\n /**\n * The canonical host (3.2.1): a production deployment answers every other host (the `*.vercel.app` alias, the\n * deployment URLs) with `X-Robots-Tag: noindex`.\n */\n canonicalHost?: string\n}\n\nexport function buildBrandlabHeaderRules(options: BrandlabHeaderRuleOptions): BrandlabHeaderRule[] {\n if (!options || typeof options !== 'object') {\n throw new TypeError('Header options are required.')\n }\n const { staticAssetCache, canonicalHost, ...security } = options\n const folders = staticAssetCache === undefined ? [] : parseBrandlabStaticAssetCache(staticAssetCache)\n const rules: BrandlabHeaderRule[] = [\n {\n source: '/:path*',\n headers: buildBrandlabSecurityHeaders(security),\n },\n ]\n // A production build outside a Preview; Previews keep private, no-store everywhere.\n const { environment, productionBuild } = options.deployment\n if (productionBuild && environment !== 'preview') {\n for (const folder of folders) {\n rules.push({\n source: folder.fileSource,\n headers: [{ key: 'Cache-Control', value: BRANDLAB_STATIC_ASSET_CACHE_CONTROL }],\n })\n }\n }\n // Only a real production deployment: Previews already send noindex everywhere, and development has no crawlers.\n if (environment === 'production' && canonicalHost) rules.push(buildNonCanonicalHostRule(canonicalHost))\n return rules\n}\n"]}
@@ -1,6 +1,7 @@
1
1
  import { type BrandlabRedirectTable } from '@brandlab-digital/site-core/redirects';
2
2
  import { type BrandlabDeploymentEnvironment, type BrandlabEnvironment, type BrandlabSite } from '@brandlab-digital/site-core/site';
3
3
  import { type BrandlabContentSecurityPolicyExtensions, type BrandlabHeader, type BrandlabHeaderRule, type BrandlabHstsExpansion } from './headers.js';
4
+ import { type SiteIconSet } from './ui-kit/icon-glyphs.js';
4
5
  /** Next redirect records: 308 stays `permanent: true`, 301 stays `statusCode: 301`. */
5
6
  export type BrandlabNextRedirect = {
6
7
  source: string;
@@ -56,6 +57,8 @@ export interface BrandlabNextConfig {
56
57
  skipTrailingSlashRedirect: true;
57
58
  env: {
58
59
  NEXT_PUBLIC_VERCEL_ENV: BrandlabDeploymentEnvironment;
60
+ /** The icon sprite's path (3.14.0), set when `icons` is declared. */
61
+ NEXT_PUBLIC_BL_ICON_SPRITE?: string;
59
62
  };
60
63
  outputFileTracingIncludes: Record<string, string[]>;
61
64
  images: BrandlabNextImagesConfig;
@@ -91,11 +94,24 @@ export interface BrandlabNextConfigOptions {
91
94
  * no route under `src/app` or `app` may answer below it.
92
95
  */
93
96
  staticAssetCache?: readonly string[];
97
+ /**
98
+ * The ui-kit icon sprite (3.14.0). When declared, loading the config writes `public/_bl/icons.<hash>.svg` (every
99
+ * library icon plus `site`, the set from `defineSiteIcons`), removes older sprites there and sets
100
+ * `NEXT_PUBLIC_BL_ICON_SPRITE`, so every `Icon` renders a `<use>` into the sprite instead of an inline glyph.
101
+ * Production builds serve the file with `public, max-age=31536000, immutable`. Add `public/_bl/` to `.gitignore`.
102
+ */
103
+ icons?: BrandlabIconsOptions;
94
104
  /** Defaults to process.env; tests supply a fixed environment. */
95
105
  environment?: BrandlabEnvironment;
96
- /** The project root for the staticAssetCache checks; defaults to process.cwd(). */
106
+ /** The project root for the staticAssetCache checks and the icon sprite; defaults to process.cwd(). */
97
107
  projectRoot?: string;
98
108
  }
109
+ export interface BrandlabIconsOptions {
110
+ /** The site's own icons, from `defineSiteIcons` (`src/site/icons.ts`). */
111
+ site?: SiteIconSet;
112
+ }
113
+ /** The sprite's cache rule: content-hashed names only, so a stale name never reaches a page. */
114
+ export declare const BRANDLAB_ICON_SPRITE_SOURCE = "/_bl/:file(icons\\.[0-9a-f]+\\.svg)";
99
115
  /** Sharp's Linux binaries must travel with the contact route's image sanitiser. */
100
116
  export declare const BRANDLAB_CONTACT_OUTPUT_FILE_TRACING: Readonly<{
101
117
  '/api/contact': readonly string[];