@carlonicora/nextjs-jsonapi 3.28.2 → 4.0.1

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 (213) hide show
  1. package/dist/{BlockNoteEditor-36VL2TNM.js → BlockNoteEditor-BXMTGKDV.js} +21 -20
  2. package/dist/BlockNoteEditor-BXMTGKDV.js.map +1 -0
  3. package/dist/{BlockNoteEditor-4IDXT6ZU.mjs → BlockNoteEditor-FGVQP35E.mjs} +9 -8
  4. package/dist/{BlockNoteEditor-4IDXT6ZU.mjs.map → BlockNoteEditor-FGVQP35E.mjs.map} +1 -1
  5. package/dist/{BlockNoteViewer-KRTAFTUX.mjs → BlockNoteViewer-DQYT6IJW.mjs} +3 -3
  6. package/dist/{BlockNoteViewer-7BFNTN4E.js → BlockNoteViewer-YH7G66PS.js} +4 -4
  7. package/dist/{BlockNoteViewer-7BFNTN4E.js.map → BlockNoteViewer-YH7G66PS.js.map} +1 -1
  8. package/dist/{HandbookSectionInterface-DgYH--Ik.d.mts → HandbookSectionInterface-BYsmnPEz.d.mts} +1 -1
  9. package/dist/{HandbookSectionInterface-Ck1wZJ81.d.ts → HandbookSectionInterface-OePKPbqN.d.ts} +1 -1
  10. package/dist/{JsonApiRequest-UHOWNI3O.mjs → JsonApiRequest-MEP6AXDT.mjs} +2 -2
  11. package/dist/JsonApiRequest-OB6WL4LA.js +26 -0
  12. package/dist/{JsonApiRequest-TE5WD3WS.js.map → JsonApiRequest-OB6WL4LA.js.map} +1 -1
  13. package/dist/ServerSession-DFNNNkg7.d.ts +29 -0
  14. package/dist/ServerSession-DK1z0_Ql.d.mts +29 -0
  15. package/dist/{analytics.modules-CfczU12P.d.ts → analytics.modules-Bsacu6ik.d.ts} +1 -1
  16. package/dist/{analytics.modules-pzGwMASI.d.mts → analytics.modules-txdCLNG-.d.mts} +1 -1
  17. package/dist/billing/index.js +404 -402
  18. package/dist/billing/index.js.map +1 -1
  19. package/dist/billing/index.mjs +10 -8
  20. package/dist/billing/index.mjs.map +1 -1
  21. package/dist/{chunk-7B4E42R5.mjs → chunk-3CZ2GK5R.mjs} +33 -9
  22. package/dist/chunk-3CZ2GK5R.mjs.map +1 -0
  23. package/dist/{chunk-4Z45NYPR.js → chunk-5W5MN7JP.js} +14 -83
  24. package/dist/chunk-5W5MN7JP.js.map +1 -0
  25. package/dist/{chunk-CAKOFJZD.js → chunk-6OFANJTE.js} +195 -142
  26. package/dist/chunk-6OFANJTE.js.map +1 -0
  27. package/dist/{chunk-5TRFNKJS.js → chunk-6TUB7Y6T.js} +23 -25
  28. package/dist/chunk-6TUB7Y6T.js.map +1 -0
  29. package/dist/{chunk-37DMFVLN.mjs → chunk-7SCREMTX.mjs} +4 -3
  30. package/dist/{chunk-37DMFVLN.mjs.map → chunk-7SCREMTX.mjs.map} +1 -1
  31. package/dist/{chunk-Q6J2ICKO.mjs → chunk-ADZSH3MG.mjs} +3 -72
  32. package/dist/chunk-ADZSH3MG.mjs.map +1 -0
  33. package/dist/{chunk-E3I4MT53.mjs → chunk-AQZ3T4BR.mjs} +71 -18
  34. package/dist/chunk-AQZ3T4BR.mjs.map +1 -0
  35. package/dist/{chunk-SJLXPHK5.mjs → chunk-E2LENTUH.mjs} +22 -24
  36. package/dist/chunk-E2LENTUH.mjs.map +1 -0
  37. package/dist/chunk-EBEXWN3I.mjs +17 -0
  38. package/dist/chunk-EBEXWN3I.mjs.map +1 -0
  39. package/dist/chunk-G4JR3SFV.js +28 -0
  40. package/dist/chunk-G4JR3SFV.js.map +1 -0
  41. package/dist/chunk-IK65TDQH.js +15 -0
  42. package/dist/chunk-IK65TDQH.js.map +1 -0
  43. package/dist/{chunk-GHFMKH3M.js → chunk-JEQ34Z5E.js} +62 -38
  44. package/dist/chunk-JEQ34Z5E.js.map +1 -0
  45. package/dist/{chunk-KOTKKBJT.js → chunk-NFCXUAE6.js} +1328 -1183
  46. package/dist/chunk-NFCXUAE6.js.map +1 -0
  47. package/dist/chunk-OEOK6A3P.js +17 -0
  48. package/dist/chunk-OEOK6A3P.js.map +1 -0
  49. package/dist/chunk-PW42LRSL.mjs +28 -0
  50. package/dist/chunk-PW42LRSL.mjs.map +1 -0
  51. package/dist/{chunk-U65JPKO3.mjs → chunk-R6ZYB4VN.mjs} +2 -2
  52. package/dist/chunk-T7SAW3UW.mjs +15 -0
  53. package/dist/chunk-T7SAW3UW.mjs.map +1 -0
  54. package/dist/{chunk-GKDHYB2R.js → chunk-TWBZ2GSX.js} +3 -3
  55. package/dist/{chunk-GKDHYB2R.js.map → chunk-TWBZ2GSX.js.map} +1 -1
  56. package/dist/{chunk-26MQIJNB.js → chunk-WGVNJXZO.js} +4 -3
  57. package/dist/chunk-WGVNJXZO.js.map +1 -0
  58. package/dist/{chunk-HPUV6FBW.mjs → chunk-WJC4SYRL.mjs} +2131 -1986
  59. package/dist/chunk-WJC4SYRL.mjs.map +1 -0
  60. package/dist/client/index.d.mts +3 -3
  61. package/dist/client/index.d.ts +3 -3
  62. package/dist/client/index.js +6 -5
  63. package/dist/client/index.js.map +1 -1
  64. package/dist/client/index.mjs +13 -12
  65. package/dist/components/index.d.mts +16 -6
  66. package/dist/components/index.d.ts +16 -6
  67. package/dist/components/index.js +8 -5
  68. package/dist/components/index.js.map +1 -1
  69. package/dist/components/index.mjs +9 -6
  70. package/dist/{config-3N1LSrcC.d.ts → config-B6UB7j69.d.ts} +1 -1
  71. package/dist/{config-BmufyEj6.d.mts → config-CEAq4Ozy.d.mts} +10 -1
  72. package/dist/{config-AVtuvfM1.d.ts → config-DydDHCfb.d.ts} +10 -1
  73. package/dist/{config-BSKTYDoh.d.mts → config-n-ZpPmOL.d.mts} +1 -1
  74. package/dist/contexts/index.d.mts +28 -4
  75. package/dist/contexts/index.d.ts +28 -4
  76. package/dist/contexts/index.js +10 -5
  77. package/dist/contexts/index.js.map +1 -1
  78. package/dist/contexts/index.mjs +9 -4
  79. package/dist/core/index.d.mts +6 -6
  80. package/dist/core/index.d.ts +6 -6
  81. package/dist/core/index.js +5 -5
  82. package/dist/core/index.js.map +1 -1
  83. package/dist/core/index.mjs +4 -4
  84. package/dist/features/analytics/index.d.mts +2 -2
  85. package/dist/features/analytics/index.d.ts +2 -2
  86. package/dist/features/analytics/index.js +124 -123
  87. package/dist/features/analytics/index.js.map +1 -1
  88. package/dist/features/analytics/index.mjs +6 -5
  89. package/dist/features/analytics/index.mjs.map +1 -1
  90. package/dist/features/analytics/server.d.mts +10 -2
  91. package/dist/features/analytics/server.d.ts +10 -2
  92. package/dist/features/analytics/server.js +8 -6
  93. package/dist/features/analytics/server.js.map +1 -1
  94. package/dist/features/analytics/server.mjs +6 -4
  95. package/dist/features/analytics/server.mjs.map +1 -1
  96. package/dist/features/help/index.d.mts +2 -2
  97. package/dist/features/help/index.d.ts +2 -2
  98. package/dist/features/help/index.js +39 -38
  99. package/dist/features/help/index.js.map +1 -1
  100. package/dist/features/help/index.mjs +5 -4
  101. package/dist/features/help/index.mjs.map +1 -1
  102. package/dist/features/tokenusage/index.d.mts +2 -2
  103. package/dist/features/tokenusage/index.d.ts +2 -2
  104. package/dist/features/tokenusage/index.js +127 -126
  105. package/dist/features/tokenusage/index.js.map +1 -1
  106. package/dist/features/tokenusage/index.mjs +6 -5
  107. package/dist/features/tokenusage/index.mjs.map +1 -1
  108. package/dist/index.d.mts +5 -5
  109. package/dist/index.d.ts +5 -5
  110. package/dist/index.js +8 -6
  111. package/dist/index.js.map +1 -1
  112. package/dist/index.mjs +11 -9
  113. package/dist/next-config/index.d.mts +43 -0
  114. package/dist/next-config/index.d.ts +43 -0
  115. package/dist/next-config/index.js +230 -0
  116. package/dist/next-config/index.js.map +1 -0
  117. package/dist/next-config/index.mjs +230 -0
  118. package/dist/next-config/index.mjs.map +1 -0
  119. package/dist/{request-FXF63NSQ.mjs → request-3IUMNVOS.mjs} +2 -2
  120. package/dist/request-LZBAOT3Y.js +8 -0
  121. package/dist/request-LZBAOT3Y.js.map +1 -0
  122. package/dist/server/cachedGet.d.mts +28 -0
  123. package/dist/server/cachedGet.d.ts +28 -0
  124. package/dist/server/cachedGet.js +60 -0
  125. package/dist/server/cachedGet.js.map +1 -0
  126. package/dist/server/cachedGet.mjs +60 -0
  127. package/dist/server/cachedGet.mjs.map +1 -0
  128. package/dist/server/cachedGet.stub.d.mts +7 -0
  129. package/dist/server/cachedGet.stub.d.ts +7 -0
  130. package/dist/server/cachedGet.stub.js +17 -0
  131. package/dist/server/cachedGet.stub.js.map +1 -0
  132. package/dist/server/cachedGet.stub.mjs +17 -0
  133. package/dist/server/cachedGet.stub.mjs.map +1 -0
  134. package/dist/server/index.d.mts +27 -25
  135. package/dist/server/index.d.ts +27 -25
  136. package/dist/server/index.js +13 -6
  137. package/dist/server/index.js.map +1 -1
  138. package/dist/server/index.mjs +15 -8
  139. package/dist/testing/index.js.map +1 -1
  140. package/dist/testing/index.mjs.map +1 -1
  141. package/dist/{types-D0uLYV2P.d.mts → types-Bq_UA8Lg.d.mts} +1 -1
  142. package/dist/{types-Cn153-mH.d.ts → types-bsm8Fp55.d.ts} +1 -1
  143. package/dist/{usePageUrlGenerator-B-3FRKBD.d.ts → usePageUrlGenerator-J_ECv-oQ.d.ts} +1 -1
  144. package/dist/{usePageUrlGenerator-CCJ_cdQy.d.mts → usePageUrlGenerator-uKBjAKqC.d.mts} +1 -1
  145. package/package.json +12 -1
  146. package/src/components/forms/__tests__/EditorSheet.test.tsx +25 -1
  147. package/src/components/forms/__tests__/useEditorDialog.test.ts +164 -1
  148. package/src/components/forms/useEditorDialog.ts +69 -2
  149. package/src/contexts/SessionContext.tsx +20 -0
  150. package/src/contexts/SocketContext.tsx +30 -3
  151. package/src/contexts/ViewportContext.tsx +17 -2
  152. package/src/contexts/__tests__/SessionContext.test.tsx +58 -0
  153. package/src/contexts/__tests__/SocketContext.session.test.tsx +77 -0
  154. package/src/contexts/index.ts +1 -0
  155. package/src/core/abstracts/AbstractService.ts +4 -6
  156. package/src/core/abstracts/__tests__/AbstractService.locale.test.ts +64 -0
  157. package/src/features/analytics/components/server/AnalyticsAdminPage.tsx +5 -1
  158. package/src/features/analytics/components/server/AnalyticsJourneyPage.tsx +5 -1
  159. package/src/features/notification/contexts/NotificationContext.tsx +5 -2
  160. package/src/features/notification/contexts/__tests__/NotificationContext.spec.tsx +22 -0
  161. package/src/features/user/contexts/CurrentUserContext.tsx +7 -5
  162. package/src/features/user/contexts/__tests__/CurrentUserContext.spec.tsx +63 -3
  163. package/src/hooks/__tests__/useSocket.render.test.ts +22 -0
  164. package/src/hooks/useSocket.ts +0 -1
  165. package/src/i18n/__tests__/config.test.ts +27 -0
  166. package/src/i18n/config.ts +17 -0
  167. package/src/i18n/index.ts +2 -0
  168. package/src/next-config/__tests__/createNextConfig.test.ts +268 -0
  169. package/src/next-config/csp.ts +128 -0
  170. package/src/next-config/headers.ts +61 -0
  171. package/src/next-config/index.ts +114 -0
  172. package/src/server/JsonApiServer.ts +3 -1
  173. package/src/server/ServerSession.ts +29 -1
  174. package/src/server/__tests__/JsonApiServer.test.ts +41 -0
  175. package/src/server/__tests__/ServerSession.read.test.ts +67 -0
  176. package/src/server/__tests__/cache.test.ts +48 -0
  177. package/src/server/__tests__/cachedGet.test.ts +125 -0
  178. package/src/server/__tests__/request.test.ts +96 -1
  179. package/src/server/cache.ts +21 -4
  180. package/src/server/cacheDigest.ts +10 -0
  181. package/src/server/cacheProfiles.ts +15 -0
  182. package/src/server/cachedGet.stub.ts +15 -0
  183. package/src/server/cachedGet.ts +80 -0
  184. package/src/server/request.ts +33 -32
  185. package/src/shadcnui/ui/__tests__/sidebar.menu-skeleton.test.tsx +50 -0
  186. package/src/shadcnui/ui/__tests__/sidebar.session.test.tsx +133 -0
  187. package/src/shadcnui/ui/sidebar.tsx +121 -18
  188. package/src/testing/matchers/jsonApiMatchers.ts +1 -0
  189. package/src/unified/JsonApiRequest.ts +4 -1
  190. package/src/unified/__tests__/JsonApiGet.cache.test.ts +51 -0
  191. package/src/utils/__tests__/map-thenable.test.ts +29 -0
  192. package/src/utils/__tests__/use-mobile.test.tsx +102 -1
  193. package/src/utils/map-thenable.ts +15 -0
  194. package/src/utils/use-mobile.tsx +13 -4
  195. package/dist/BlockNoteEditor-36VL2TNM.js.map +0 -1
  196. package/dist/JsonApiRequest-TE5WD3WS.js +0 -26
  197. package/dist/chunk-26MQIJNB.js.map +0 -1
  198. package/dist/chunk-4Z45NYPR.js.map +0 -1
  199. package/dist/chunk-5TRFNKJS.js.map +0 -1
  200. package/dist/chunk-7B4E42R5.mjs.map +0 -1
  201. package/dist/chunk-CAKOFJZD.js.map +0 -1
  202. package/dist/chunk-E3I4MT53.mjs.map +0 -1
  203. package/dist/chunk-GHFMKH3M.js.map +0 -1
  204. package/dist/chunk-HPUV6FBW.mjs.map +0 -1
  205. package/dist/chunk-KOTKKBJT.js.map +0 -1
  206. package/dist/chunk-Q6J2ICKO.mjs.map +0 -1
  207. package/dist/chunk-SJLXPHK5.mjs.map +0 -1
  208. package/dist/request-ZTX6I63X.js +0 -8
  209. package/dist/request-ZTX6I63X.js.map +0 -1
  210. /package/dist/{BlockNoteViewer-KRTAFTUX.mjs.map → BlockNoteViewer-DQYT6IJW.mjs.map} +0 -0
  211. /package/dist/{JsonApiRequest-UHOWNI3O.mjs.map → JsonApiRequest-MEP6AXDT.mjs.map} +0 -0
  212. /package/dist/{chunk-U65JPKO3.mjs.map → chunk-R6ZYB4VN.mjs.map} +0 -0
  213. /package/dist/{request-FXF63NSQ.mjs.map → request-3IUMNVOS.mjs.map} +0 -0
@@ -0,0 +1,128 @@
1
+ // Runtime imports: none. next.config.js loads this at RUNTIME under `next start`
2
+ // in an image installed with --prod, so it may import only Node built-ins.
3
+
4
+ export type CspDirective =
5
+ "script-src" | "style-src" | "img-src" | "font-src" | "connect-src" | "frame-src" | "worker-src";
6
+
7
+ /**
8
+ * Third-party services an app may load. Each one adds its hosts to the
9
+ * directives it needs; nothing is allowed unless the app names it.
10
+ *
11
+ * - `stripe`: the billing feature (`@carlonicora/nextjs-jsonapi/billing`).
12
+ * - `googleMaps`: `FormPlaceAutocomplete` (Places API) and Maps JS/static tiles.
13
+ * - `turnstile`: Cloudflare Turnstile widgets (contact and waitlist forms).
14
+ * - `discord`: Discord avatars shown for users who log in with Discord.
15
+ * - `googleFonts`: fonts linked at runtime from Google Fonts. Not needed for
16
+ * `next/font/google`, which self-hosts at build time.
17
+ */
18
+ export type CspService = "stripe" | "googleMaps" | "turnstile" | "discord" | "googleFonts";
19
+
20
+ export interface CspOptions {
21
+ services?: CspService[];
22
+ csp?: Partial<Record<CspDirective, string[]>>;
23
+ }
24
+
25
+ const SERVICE_SOURCES: Record<CspService, Partial<Record<CspDirective, string[]>>> = {
26
+ stripe: {
27
+ "script-src": ["https://js.stripe.com"],
28
+ "img-src": ["https://*.stripe.com"],
29
+ "connect-src": ["https://api.stripe.com"],
30
+ "frame-src": ["https://js.stripe.com", "https://hooks.stripe.com"],
31
+ },
32
+ googleMaps: {
33
+ "script-src": ["https://maps.googleapis.com"],
34
+ "img-src": ["https://maps.googleapis.com", "https://maps.gstatic.com"],
35
+ "connect-src": ["https://maps.googleapis.com", "https://places.googleapis.com"],
36
+ },
37
+ turnstile: {
38
+ "script-src": ["https://challenges.cloudflare.com"],
39
+ "connect-src": ["https://challenges.cloudflare.com"],
40
+ "frame-src": ["https://challenges.cloudflare.com"],
41
+ },
42
+ discord: {
43
+ "img-src": ["https://cdn.discordapp.com"],
44
+ },
45
+ googleFonts: {
46
+ "style-src": ["https://fonts.googleapis.com"],
47
+ "font-src": ["https://fonts.gstatic.com"],
48
+ },
49
+ };
50
+
51
+ function listImageSources(env: NodeJS.ProcessEnv): string[] {
52
+ return env.IMAGE_SOURCES ? env.IMAGE_SOURCES.split(",").map((s) => s.trim()) : [];
53
+ }
54
+
55
+ // Build CSP directives
56
+ // Note: 'unsafe-inline' and 'unsafe-eval' needed for Next.js dev mode and some runtime features
57
+ // In production, consider using nonces for stricter CSP
58
+ export function buildCsp(options: CspOptions, env: NodeJS.ProcessEnv): string {
59
+ const apiUrl = env.NEXT_PUBLIC_API_URL || "";
60
+ const imageSources = listImageSources(env);
61
+ const services = options.services ?? [];
62
+
63
+ // Each fetch directive: what Next itself needs, then the named services, then
64
+ // the app's extras, then the sources derived from the env.
65
+ const fetchDirectives: Record<CspDirective, { base: string[]; fromEnv: string[] }> = {
66
+ // Scripts: self, and unsafe-inline/eval for Next.js
67
+ "script-src": { base: ["'self'", "'unsafe-inline'", "'unsafe-eval'"], fromEnv: [] },
68
+ // Styles: self and unsafe-inline for styled-components/emotion
69
+ "style-src": { base: ["'self'", "'unsafe-inline'"], fromEnv: [] },
70
+ // Images: self, data URIs, blobs, and configured image sources
71
+ "img-src": { base: ["'self'", "data:", "blob:"], fromEnv: imageSources },
72
+ // Fonts: self
73
+ "font-src": { base: ["'self'"], fromEnv: [] },
74
+ // Connect: self, API (http + ws for socket.io), and storage (for uploads)
75
+ "connect-src": {
76
+ base: ["'self'"],
77
+ fromEnv: [
78
+ apiUrl,
79
+ // Add WebSocket URL for socket.io (convert http->ws, https->wss)
80
+ apiUrl.replace(/^http/, "ws"),
81
+ env.NEXT_PUBLIC_ADDRESS || "",
82
+ ...imageSources,
83
+ ],
84
+ },
85
+ // Frames: self
86
+ "frame-src": { base: ["'self'"], fromEnv: [] },
87
+ // Worker sources: self and blob (for Web Workers with dynamic imports)
88
+ "worker-src": { base: ["'self'", "blob:"], fromEnv: [] },
89
+ };
90
+
91
+ const directive = (name: CspDirective): string => {
92
+ const { base, fromEnv } = fetchDirectives[name];
93
+ const fromServices = services.flatMap((service) => SERVICE_SOURCES[service][name] ?? []);
94
+ const sources = [...base, ...fromServices, ...(options.csp?.[name] ?? []), ...fromEnv].filter(
95
+ (source) => source !== "",
96
+ );
97
+ return [name, ...new Set(sources)].join(" ");
98
+ };
99
+
100
+ return [
101
+ "default-src 'self'",
102
+ directive("script-src"),
103
+ directive("style-src"),
104
+ directive("img-src"),
105
+ directive("font-src"),
106
+ directive("connect-src"),
107
+ directive("frame-src"),
108
+ // Frame ancestors: deny embedding (equivalent to X-Frame-Options: DENY)
109
+ "frame-ancestors 'none'",
110
+ // Form actions: only self
111
+ "form-action 'self'",
112
+ // Base URI: only self
113
+ "base-uri 'self'",
114
+ // Object sources: none (no plugins)
115
+ "object-src 'none'",
116
+ directive("worker-src"),
117
+ // Upgrade insecure requests in production.
118
+ //
119
+ // NOT under E2E_BUILD: the e2e harness runs a PRODUCTION build (for speed)
120
+ // and serves it over plain http on a custom hostname. With this directive
121
+ // the browser rewrites every /_next/static asset to https, which nothing is
122
+ // listening on — every chunk fails with ERR_SSL_PROTOCOL_ERROR, the page
123
+ // never hydrates, and each spec sees only server-rendered markup.
124
+ env.NODE_ENV === "production" && env.E2E_BUILD !== "true" ? "upgrade-insecure-requests" : "",
125
+ ]
126
+ .filter(Boolean)
127
+ .join("; ");
128
+ }
@@ -0,0 +1,61 @@
1
+ // Runtime imports: relative only. See csp.ts.
2
+ import { buildCsp, type CspOptions } from "./csp";
3
+
4
+ export interface SecurityHeaderOptions extends CspOptions {
5
+ permissionsPolicy?: { camera?: "none" | "self" };
6
+ }
7
+
8
+ export interface HeaderRule {
9
+ source: string;
10
+ headers: { key: string; value: string }[];
11
+ }
12
+
13
+ export function buildSecurityHeaders(options: SecurityHeaderOptions, env: NodeJS.ProcessEnv): HeaderRule[] {
14
+ const camera = options.permissionsPolicy?.camera === "none" ? "" : "self";
15
+
16
+ return [
17
+ {
18
+ // Apply to all routes
19
+ source: "/:path*",
20
+ headers: [
21
+ // Content Security Policy - prevents XSS attacks
22
+ {
23
+ key: "Content-Security-Policy",
24
+ value: buildCsp(options, env),
25
+ },
26
+ // X-Frame-Options - prevents clickjacking (backup for older browsers)
27
+ {
28
+ key: "X-Frame-Options",
29
+ value: "DENY",
30
+ },
31
+ // X-Content-Type-Options - prevents MIME sniffing
32
+ {
33
+ key: "X-Content-Type-Options",
34
+ value: "nosniff",
35
+ },
36
+ // Strict-Transport-Security - enforces HTTPS (1 year, include subdomains).
37
+ // Omitted under E2E_BUILD for the same reason as upgrade-insecure-requests:
38
+ // the suite speaks plain http, and an HSTS pin would force https on every
39
+ // later request to this host from the same browser profile.
40
+ ...(env.E2E_BUILD === "true"
41
+ ? []
42
+ : [
43
+ {
44
+ key: "Strict-Transport-Security",
45
+ value: "max-age=31536000; includeSubDomains",
46
+ },
47
+ ]),
48
+ // Referrer-Policy - controls referrer information
49
+ {
50
+ key: "Referrer-Policy",
51
+ value: "strict-origin-when-cross-origin",
52
+ },
53
+ // Permissions-Policy - restricts browser features
54
+ {
55
+ key: "Permissions-Policy",
56
+ value: `camera=(${camera}), microphone=(), geolocation=()`,
57
+ },
58
+ ],
59
+ },
60
+ ];
61
+ }
@@ -0,0 +1,114 @@
1
+ // Shared next.config for the nja fleet. Apps wrap the result with their own
2
+ // plugins: `withNextIntl(withMDX(createNextConfig({...})))`.
3
+ //
4
+ // next.config.js is loaded at RUNTIME by `next start`, and the production image
5
+ // installs with --prod. This entry may therefore import only Node built-ins at
6
+ // runtime: never next-intl, @next/mdx, serwist or any devDependency. `next` is
7
+ // imported for its types only.
8
+ import type { NextConfig } from "next";
9
+ import { buildCsp, type CspDirective, type CspService } from "./csp";
10
+ import { buildSecurityHeaders } from "./headers";
11
+
12
+ export { buildCsp };
13
+ export type { CspDirective, CspService };
14
+
15
+ export interface NjaNextConfigOptions {
16
+ devOrigins: string[];
17
+ /** Third-party services the app loads; each adds its own hosts to the CSP. Nothing is allowed by default. */
18
+ services?: CspService[];
19
+ /** Extra CSP sources per directive, for hosts no named service covers. */
20
+ csp?: Partial<Record<CspDirective, string[]>>;
21
+ env?: Record<string, string>;
22
+ imageMinimumCacheTTL?: number;
23
+ permissionsPolicy?: { camera?: "none" | "self" };
24
+ mdx?: boolean;
25
+ serwist?: boolean;
26
+ securityHeaders?: boolean;
27
+ authInterrupts?: boolean;
28
+ reactCompiler?: boolean;
29
+ rewrites?: NextConfig["rewrites"];
30
+ extend?: (config: NextConfig) => NextConfig;
31
+ }
32
+
33
+ type RemotePattern = { protocol: "http" | "https"; hostname: string; port?: string };
34
+
35
+ function parseImageSources(env: NodeJS.ProcessEnv): RemotePattern[] {
36
+ return env.IMAGE_SOURCES
37
+ ? env.IMAGE_SOURCES.split(",").map((url) => {
38
+ const { hostname, protocol, port } = new URL(url.trim());
39
+ return {
40
+ protocol: protocol.replace(":", "") as RemotePattern["protocol"],
41
+ hostname,
42
+ port: port || undefined,
43
+ };
44
+ })
45
+ : [];
46
+ }
47
+
48
+ export function createNextConfig(options: NjaNextConfigOptions): NextConfig {
49
+ const env = process.env;
50
+ const reactCompiler = options.reactCompiler === true;
51
+
52
+ const config: NextConfig = {
53
+ // The e2e stack (scripts/e2e.sh) runs a PRODUCTION build of this app while a
54
+ // `next dev` server may still be running against the same worktree. `next
55
+ // build` and `next dev` share `.next`, so a build performed underneath a live
56
+ // dev server clobbers it and that dev server then 404s every route. Giving
57
+ // the e2e build its own directory keeps the two independent. E2E_BUILD must
58
+ // be set for `next build` AND for `next start`, or `start` looks in `.next`
59
+ // and serves someone else's output.
60
+ ...(env.E2E_BUILD === "true" ? { distDir: ".next-e2e" } : {}),
61
+ cacheComponents: true,
62
+ partialPrefetching: true,
63
+ reactCompiler,
64
+ experimental: {
65
+ serverActions: {
66
+ bodySizeLimit: "5mb",
67
+ },
68
+ // Required by `unauthorized()` (next/navigation), which ServerSession.checkPermission
69
+ // calls to deny access. Without this flag the function throws a plain Error instead of
70
+ // rendering the nearest unauthorized.tsx.
71
+ authInterrupts: options.authInterrupts ?? true,
72
+ turbopackGc: true,
73
+ turbopackLazyDynamicImports: true,
74
+ ...(reactCompiler ? { turbopackRustReactCompiler: true } : {}),
75
+ },
76
+ allowedDevOrigins: options.devOrigins,
77
+ pageExtensions: options.mdx ? ["ts", "tsx", "mdx"] : ["ts", "tsx"],
78
+ images: {
79
+ minimumCacheTTL: options.imageMinimumCacheTTL ?? 60,
80
+ remotePatterns: parseImageSources(env),
81
+ dangerouslyAllowLocalIP: true,
82
+ },
83
+ poweredByHeader: false,
84
+ reactStrictMode: false,
85
+ env: {
86
+ NEXT_PUBLIC_API_URL: env.NEXT_PUBLIC_API_URL ?? "",
87
+ NEXT_PUBLIC_ADDRESS: env.NEXT_PUBLIC_ADDRESS ?? "",
88
+ NEXT_PUBLIC_VAPID_PUBLIC_KEY: env.NEXT_PUBLIC_VAPID_PUBLIC_KEY ?? "",
89
+ NEXT_PUBLIC_GOOGLE_MAPS_API_KEY: env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY ?? "",
90
+ ...options.env,
91
+ },
92
+ // Inlined replacement for @serwist/turbopack's withSerwist wrapper, which
93
+ // does nothing but append these two entries. Requiring the package here
94
+ // instead CRASHES production: it is a devDependency, next.config.js is loaded
95
+ // at RUNTIME by `next start`, and the web-production image installs with
96
+ // --prod. Do not re-add a require of any devDependency to this file.
97
+ ...(options.serwist ? { serverExternalPackages: ["esbuild", "esbuild-wasm"] } : {}),
98
+ // Next 16 builds AND serves dev with Turbopack, so this is what pins yjs to a
99
+ // single instance in both modes.
100
+ turbopack: {
101
+ resolveAlias: {
102
+ yjs: "./node_modules/yjs",
103
+ },
104
+ },
105
+ };
106
+
107
+ if (options.securityHeaders !== false) {
108
+ config.headers = async () => buildSecurityHeaders(options, process.env);
109
+ }
110
+
111
+ if (options.rewrites) config.rewrites = options.rewrites;
112
+
113
+ return options.extend ? options.extend(config) : config;
114
+ }
@@ -83,7 +83,9 @@ export async function ServerJsonApiGet(params: {
83
83
  language: string;
84
84
  }): Promise<ApiResponseInterface> {
85
85
  runServerBootstrapper();
86
- const token = await getServerToken();
86
+ // A cached GET never sends the token, so it must not read the cookie either:
87
+ // the read would make a public page dynamic.
88
+ const token = params.classKey.cache ? undefined : await getServerToken();
87
89
 
88
90
  const apiResponse = await serverRequest({
89
91
  method: "GET",
@@ -1,9 +1,37 @@
1
- import { cookies } from "next/headers";
1
+ import { cookies, headers } from "next/headers";
2
2
  import { redirect } from "next/navigation";
3
+ import { userAgent } from "next/server";
3
4
  import { ungzip } from "pako";
4
5
  import { Action, checkPermissionsFromServer, ModuleWithPermissions } from "../permissions";
6
+ import { getRoleId } from "../roles/config";
7
+ import { VIEWPORT_COOKIE_NAME } from "../utils/use-mobile";
8
+
9
+ export interface SessionSnapshot {
10
+ isLogged: boolean;
11
+ isAdministrator: boolean;
12
+ token?: string;
13
+ sidebarOpen: boolean;
14
+ isMobile: boolean;
15
+ }
5
16
 
6
17
  export class ServerSession {
18
+ static async read(): Promise<SessionSnapshot> {
19
+ const cookieStore = await cookies();
20
+ const headerStore = await headers();
21
+
22
+ const token = cookieStore.get("token")?.value || undefined;
23
+ const roles = cookieStore.get("roles")?.value;
24
+ const viewportCookie = cookieStore.get(VIEWPORT_COOKIE_NAME)?.value;
25
+
26
+ return {
27
+ isLogged: !!token,
28
+ isAdministrator: !!roles && roles.includes(getRoleId().Administrator),
29
+ token,
30
+ sidebarOpen: cookieStore.get("sidebar_state")?.value !== "false",
31
+ isMobile: viewportCookie ? viewportCookie === "1" : userAgent({ headers: headerStore }).device.type === "mobile",
32
+ };
33
+ }
34
+
7
35
  static async isLogged() {
8
36
  const cookieStore = await cookies();
9
37
  const token = cookieStore.get("token");
@@ -0,0 +1,41 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const mocks = vi.hoisted(() => ({
4
+ getServerToken: vi.fn(),
5
+ serverRequest: vi.fn(),
6
+ translateResponse: vi.fn(),
7
+ }));
8
+
9
+ vi.mock("../token", () => ({ getServerToken: mocks.getServerToken }));
10
+ vi.mock("../request", () => ({ serverRequest: mocks.serverRequest }));
11
+ vi.mock("../../core/utils/translateResponse", () => ({ translateResponse: mocks.translateResponse }));
12
+
13
+ import { configureServerJsonApi, ServerJsonApiGet } from "../JsonApiServer";
14
+
15
+ describe("ServerJsonApiGet", () => {
16
+ beforeEach(() => {
17
+ vi.clearAllMocks();
18
+ configureServerJsonApi({ apiUrl: "http://api.test" });
19
+ mocks.getServerToken.mockResolvedValue("secret-token");
20
+ mocks.serverRequest.mockResolvedValue({ data: undefined, ok: true, status: 200, statusText: "OK" });
21
+ mocks.translateResponse.mockResolvedValue({});
22
+ });
23
+
24
+ it("does not read the token cookie for a cached GET", async () => {
25
+ await ServerJsonApiGet({ classKey: { name: "help", cache: "help" } as any, endpoint: "/help", language: "en" });
26
+
27
+ expect(mocks.getServerToken).not.toHaveBeenCalled();
28
+ expect(mocks.serverRequest).toHaveBeenCalledWith(
29
+ expect.objectContaining({ method: "GET", url: "http://api.test/help", cache: "help", token: undefined }),
30
+ );
31
+ });
32
+
33
+ it("reads the token cookie for an uncached GET", async () => {
34
+ await ServerJsonApiGet({ classKey: { name: "users" } as any, endpoint: "/users", language: "en" });
35
+
36
+ expect(mocks.getServerToken).toHaveBeenCalledTimes(1);
37
+ expect(mocks.serverRequest).toHaveBeenCalledWith(
38
+ expect.objectContaining({ method: "GET", url: "http://api.test/users", token: "secret-token" }),
39
+ );
40
+ });
41
+ });
@@ -0,0 +1,67 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const mocks = vi.hoisted(() => {
4
+ const state = {
5
+ cookies: {} as Record<string, string>,
6
+ deviceType: undefined as string | undefined,
7
+ };
8
+ return {
9
+ state,
10
+ cookies: vi.fn(async () => ({
11
+ get: (name: string) => (name in state.cookies ? { name, value: state.cookies[name] } : undefined),
12
+ })),
13
+ headers: vi.fn(async () => new Headers()),
14
+ userAgent: vi.fn(() => ({ device: { type: state.deviceType } })),
15
+ };
16
+ });
17
+
18
+ vi.mock("next/headers", () => ({ cookies: mocks.cookies, headers: mocks.headers }));
19
+ vi.mock("next/server", () => ({ userAgent: mocks.userAgent }));
20
+ vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
21
+
22
+ import { configureRoles } from "../../roles/config";
23
+ import { ServerSession } from "../ServerSession";
24
+
25
+ describe("ServerSession.read", () => {
26
+ beforeEach(() => {
27
+ configureRoles({ Administrator: "role-admin", CompanyAdministrator: "role-company-admin" });
28
+ mocks.state.cookies = {};
29
+ mocks.state.deviceType = undefined;
30
+ });
31
+
32
+ it("reads a logged-in administrator on a desktop with the sidebar closed", async () => {
33
+ mocks.state.cookies = {
34
+ token: "t1",
35
+ roles: "role-admin,role-x",
36
+ sidebar_state: "false",
37
+ viewport_mobile: "0",
38
+ };
39
+
40
+ await expect(ServerSession.read()).resolves.toEqual({
41
+ isLogged: true,
42
+ isAdministrator: true,
43
+ token: "t1",
44
+ sidebarOpen: false,
45
+ isMobile: false,
46
+ });
47
+ });
48
+
49
+ it("falls back to the user agent when the viewport cookie is missing", async () => {
50
+ mocks.state.cookies = { token: "t1" };
51
+ mocks.state.deviceType = "mobile";
52
+
53
+ const snapshot = await ServerSession.read();
54
+
55
+ expect(snapshot.isMobile).toBe(true);
56
+ });
57
+
58
+ it("reads a logged-out visitor", async () => {
59
+ await expect(ServerSession.read()).resolves.toEqual({
60
+ isLogged: false,
61
+ isAdministrator: false,
62
+ token: undefined,
63
+ sidebarOpen: true,
64
+ isMobile: false,
65
+ });
66
+ });
67
+ });
@@ -0,0 +1,48 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const nextCache = vi.hoisted(() => ({
4
+ cacheLife: vi.fn(),
5
+ cacheTag: vi.fn(),
6
+ revalidateTag: vi.fn(),
7
+ updateTag: vi.fn(),
8
+ }));
9
+
10
+ vi.mock("next/cache", () => nextCache);
11
+
12
+ import { CACHE_PROFILES, invalidateCacheTag, invalidateCacheTags, updateCacheTag } from "../cache";
13
+
14
+ describe("cache helpers", () => {
15
+ beforeEach(() => {
16
+ vi.clearAllMocks();
17
+ });
18
+
19
+ it("updateCacheTag calls updateTag", async () => {
20
+ await updateCacheTag("help");
21
+
22
+ expect(nextCache.updateTag).toHaveBeenCalledWith("help");
23
+ expect(nextCache.revalidateTag).not.toHaveBeenCalled();
24
+ });
25
+
26
+ it('invalidateCacheTag defaults to "max"', async () => {
27
+ await invalidateCacheTag("help");
28
+
29
+ expect(nextCache.revalidateTag).toHaveBeenCalledWith("help", "max");
30
+ });
31
+
32
+ it("invalidateCacheTag passes an explicit profile through", async () => {
33
+ await invalidateCacheTag("help", "hours");
34
+
35
+ expect(nextCache.revalidateTag).toHaveBeenCalledWith("help", "hours");
36
+ });
37
+
38
+ it("invalidateCacheTags revalidates every tag", async () => {
39
+ await invalidateCacheTags(["a", "b"]);
40
+
41
+ expect(nextCache.revalidateTag).toHaveBeenNthCalledWith(1, "a", "max");
42
+ expect(nextCache.revalidateTag).toHaveBeenNthCalledWith(2, "b", "max");
43
+ });
44
+
45
+ it("exports the built-in cacheLife profile names", () => {
46
+ expect([...CACHE_PROFILES]).toEqual(["seconds", "minutes", "hours", "days", "weeks", "max", "default"]);
47
+ });
48
+ });
@@ -0,0 +1,125 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const nextCache = vi.hoisted(() => ({
4
+ cacheLife: vi.fn(),
5
+ cacheTag: vi.fn(),
6
+ revalidateTag: vi.fn(),
7
+ updateTag: vi.fn(),
8
+ }));
9
+
10
+ vi.mock("next/cache", () => nextCache);
11
+
12
+ import { cachedServerGet, statusFromDigest } from "../cachedGet";
13
+
14
+ function okResponse(body: unknown) {
15
+ return { ok: true, status: 200, statusText: "OK", json: async () => body };
16
+ }
17
+
18
+ describe("cachedServerGet", () => {
19
+ beforeEach(() => {
20
+ vi.clearAllMocks();
21
+ });
22
+
23
+ afterEach(() => {
24
+ vi.unstubAllGlobals();
25
+ });
26
+
27
+ it("calls cacheLife for a profile name", async () => {
28
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue(okResponse({ data: [] })));
29
+
30
+ await cachedServerGet({ url: "http://api.test/help", cache: "hours", language: "en" });
31
+
32
+ expect(nextCache.cacheLife).toHaveBeenCalledWith("hours");
33
+ expect(nextCache.cacheTag).not.toHaveBeenCalled();
34
+ });
35
+
36
+ it("tags non-profile values and applies the default life", async () => {
37
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue(okResponse({ data: [] })));
38
+
39
+ await cachedServerGet({ url: "http://api.test/help", cache: "help", language: "en" });
40
+
41
+ expect(nextCache.cacheTag).toHaveBeenCalledWith("help");
42
+ expect(nextCache.cacheLife).toHaveBeenCalledWith("max");
43
+ });
44
+
45
+ it("never sends Authorization", async () => {
46
+ const fetchMock = vi.fn().mockResolvedValue(okResponse({ data: [] }));
47
+ vi.stubGlobal("fetch", fetchMock);
48
+
49
+ await cachedServerGet({
50
+ url: "http://api.test/help",
51
+ cache: "help",
52
+ language: "it",
53
+ companyId: "company-1",
54
+ additionalHeaders: { "x-extra": "1" },
55
+ });
56
+
57
+ const [url, init] = fetchMock.mock.calls[0];
58
+ expect(url).toBe("http://api.test/help");
59
+ expect(init.method).toBe("GET");
60
+ expect(init.headers).not.toHaveProperty("Authorization");
61
+ expect(init.headers["x-language"]).toBe("it");
62
+ expect(init.headers["x-companyid"]).toBe("company-1");
63
+ expect(init.headers["x-extra"]).toBe("1");
64
+ expect(init).not.toHaveProperty("next");
65
+ });
66
+
67
+ it("returns the parsed body on success", async () => {
68
+ vi.stubGlobal("fetch", vi.fn().mockResolvedValue(okResponse({ data: { id: "1" } })));
69
+
70
+ const response = await cachedServerGet({ url: "http://api.test/help", cache: "help", language: "en" });
71
+
72
+ expect(response).toEqual({ data: { data: { id: "1" } }, ok: true, status: 200, statusText: "OK" });
73
+ });
74
+
75
+ it("does not cache a failed response", async () => {
76
+ vi.stubGlobal(
77
+ "fetch",
78
+ vi
79
+ .fn()
80
+ .mockResolvedValue({ ok: false, status: 500, statusText: "Internal Server Error", json: async () => ({}) }),
81
+ );
82
+
83
+ const error = await cachedServerGet({ url: "http://api.test/help", cache: "help", language: "en" }).catch(
84
+ (e: unknown) => e,
85
+ );
86
+
87
+ expect(error).toBeInstanceOf(Error);
88
+ expect((error as any).digest).toBe("HTTP_500");
89
+ expect((error as any).status).toBe(500);
90
+ });
91
+
92
+ it("throws a 503 when the API cannot be reached", async () => {
93
+ vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new TypeError("fetch failed")));
94
+
95
+ const error = await cachedServerGet({ url: "http://api.test/help", cache: "help", language: "en" }).catch(
96
+ (e: unknown) => e,
97
+ );
98
+
99
+ expect(error).toBeInstanceOf(Error);
100
+ expect((error as any).digest).toBe("HTTP_503");
101
+ expect((error as any).status).toBe(503);
102
+ });
103
+ });
104
+
105
+ describe("statusFromDigest", () => {
106
+ it("reads the status from an HTTP digest", () => {
107
+ expect(statusFromDigest({ digest: "HTTP_404" })).toBe(404);
108
+ });
109
+
110
+ it("reads the status from a sanitised production error", () => {
111
+ const sanitised = Object.assign(new Error("An error occurred in the Server Components render."), {
112
+ digest: "HTTP_500",
113
+ });
114
+ expect(statusFromDigest(sanitised)).toBe(500);
115
+ });
116
+
117
+ it("returns undefined for a hashed digest", () => {
118
+ expect(statusFromDigest({ digest: "123456" })).toBeUndefined();
119
+ });
120
+
121
+ it("returns undefined for values without a digest", () => {
122
+ expect(statusFromDigest(undefined)).toBeUndefined();
123
+ expect(statusFromDigest(new Error("x"))).toBeUndefined();
124
+ });
125
+ });