nexus-shared 3.0.0 → 3.2.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 (162) hide show
  1. package/CHANGELOG.md +442 -0
  2. package/README.md +1123 -1000
  3. package/dist/Client.Index.d.ts +6 -0
  4. package/dist/Client.Index.js +6 -0
  5. package/dist/Components/Chats/ChatParts.d.ts +2 -2
  6. package/dist/Components/Chats/ChatParts.js +3 -7
  7. package/dist/Components/Chats/ChatThread.js +3 -3
  8. package/dist/Components/Documents/Button.d.ts +6 -1
  9. package/dist/Components/Documents/Button.js +2 -2
  10. package/dist/Components/Documents/Menu.js +21 -9
  11. package/dist/Components/Documents/SplitButton.d.ts +3 -1
  12. package/dist/Components/Documents/SplitButton.js +7 -5
  13. package/dist/Components/Documents/TabButtons.d.ts +5 -2
  14. package/dist/Components/Documents/TabButtons.js +2 -2
  15. package/dist/Components/Forms/ApiForm.d.ts +1 -1
  16. package/dist/Components/Forms/ApiForm.js +1 -1
  17. package/dist/Components/Forms/Crud.js +486 -206
  18. package/dist/Components/Forms/CrudHistory.d.ts +66 -0
  19. package/dist/Components/Forms/CrudHistory.js +140 -0
  20. package/dist/Components/Forms/CrudParts.d.ts +5 -1
  21. package/dist/Components/Forms/CrudParts.js +38 -5
  22. package/dist/Components/Forms/ExcelImport.d.ts +23 -7
  23. package/dist/Components/Forms/ExcelImport.js +189 -60
  24. package/dist/Components/Forms/Form.js +9 -14
  25. package/dist/Components/Forms/SubmitForm.d.ts +11 -2
  26. package/dist/Components/Forms/SubmitForm.js +23 -12
  27. package/dist/Components/Inputs/CodeBox.d.ts +69 -0
  28. package/dist/Components/Inputs/CodeBox.js +316 -0
  29. package/dist/Components/Inputs/DatePicker.js +4 -1
  30. package/dist/Components/Inputs/DateRangePicker.js +4 -1
  31. package/dist/Components/Inputs/DateTimePicker.js +4 -1
  32. package/dist/Components/Inputs/Dropdown.js +4 -1
  33. package/dist/Components/Inputs/DropdownParts.d.ts +1 -1
  34. package/dist/Components/Inputs/DropdownParts.js +4 -6
  35. package/dist/Components/Inputs/InputRenderer.js +3 -0
  36. package/dist/Components/Inputs/NumberBox.js +26 -2
  37. package/dist/Components/Inputs/RowsInput.js +2 -2
  38. package/dist/Components/Inputs/Slider.js +4 -1
  39. package/dist/Components/Inputs/TabularForm.js +42 -10
  40. package/dist/Components/Inputs/TextArea.js +4 -1
  41. package/dist/Components/Inputs/TextBox.js +4 -1
  42. package/dist/Components/Inputs/TimePanel.js +4 -3
  43. package/dist/Components/Inputs/TimePicker.js +4 -1
  44. package/dist/Components/Inputs/TreeView.d.ts +55 -5
  45. package/dist/Components/Inputs/TreeView.js +372 -23
  46. package/dist/Components/Layouts/Popup.js +1 -4
  47. package/dist/Components/Layouts/ThemePicker.d.ts +21 -3
  48. package/dist/Components/Layouts/ThemePicker.js +89 -6
  49. package/dist/Components/Layouts/ThemeScript.d.ts +4 -2
  50. package/dist/Components/Layouts/ThemeScript.js +4 -2
  51. package/dist/Components/Layouts/ThemeSwitcher.d.ts +6 -3
  52. package/dist/Components/Layouts/ThemeSwitcher.js +9 -8
  53. package/dist/Components/Viewers/Avatar.d.ts +21 -0
  54. package/dist/Components/Viewers/Avatar.js +21 -0
  55. package/dist/Components/Viewers/DataTable.js +252 -62
  56. package/dist/Components/Viewers/DataTableColumns.d.ts +7 -3
  57. package/dist/Components/Viewers/DataTableColumns.js +25 -68
  58. package/dist/Components/Viewers/DataTableFilters.d.ts +21 -0
  59. package/dist/Components/Viewers/DataTableFilters.js +68 -0
  60. package/dist/Components/Viewers/DataTableParts.d.ts +28 -10
  61. package/dist/Components/Viewers/DataTableParts.js +42 -13
  62. package/dist/Helpers/AnimationHelpers.d.ts +11 -0
  63. package/dist/Helpers/AnimationHelpers.js +20 -2
  64. package/dist/Helpers/ApiModules.d.ts +3 -34
  65. package/dist/Helpers/ApiModules.js +3 -3
  66. package/dist/Helpers/ApiRoutes.d.ts +68 -26
  67. package/dist/Helpers/ApiRoutes.js +31 -27
  68. package/dist/Helpers/ArrayHelpers.d.ts +0 -0
  69. package/dist/Helpers/ArrayHelpers.js +2 -0
  70. package/dist/Helpers/CodeHelpers.d.ts +97 -0
  71. package/dist/Helpers/CodeHelpers.js +332 -0
  72. package/dist/Helpers/CrudBackend.d.ts +101 -8
  73. package/dist/Helpers/CrudBackend.js +172 -11
  74. package/dist/Helpers/CrudColumns.d.ts +51 -0
  75. package/dist/Helpers/CrudColumns.js +69 -0
  76. package/dist/Helpers/CrudHelpers.d.ts +50 -5
  77. package/dist/Helpers/CrudHelpers.js +109 -12
  78. package/dist/Helpers/ExcelBackend.d.ts +3 -3
  79. package/dist/Helpers/ExcelBackend.js +8 -2
  80. package/dist/Helpers/ExcelHelpers.d.ts +36 -8
  81. package/dist/Helpers/ExcelHelpers.js +55 -13
  82. package/dist/Helpers/FormStore.d.ts +9 -5
  83. package/dist/Helpers/FormStore.js +25 -5
  84. package/dist/Helpers/InputParamsHelpers.js +8 -0
  85. package/dist/Helpers/MessageBuilder.js +3 -0
  86. package/dist/Helpers/NexusHelpers.d.ts +0 -0
  87. package/dist/Helpers/NexusHelpers.js +1 -0
  88. package/dist/Helpers/NumberHelpers.d.ts +7 -1
  89. package/dist/Helpers/NumberHelpers.js +41 -3
  90. package/dist/Helpers/PopoverHelpers.d.ts +17 -5
  91. package/dist/Helpers/PopoverHelpers.js +52 -17
  92. package/dist/Helpers/PopoverHooks.d.ts +24 -0
  93. package/dist/Helpers/PopoverHooks.js +62 -0
  94. package/dist/Helpers/RequestCache.d.ts +35 -0
  95. package/dist/Helpers/RequestCache.js +113 -0
  96. package/dist/Helpers/RowsStore.d.ts +7 -2
  97. package/dist/Helpers/RowsStore.js +15 -3
  98. package/dist/Helpers/TableFilters.d.ts +25 -0
  99. package/dist/Helpers/TableFilters.js +162 -0
  100. package/dist/Helpers/TableHelpers.d.ts +7 -4
  101. package/dist/Helpers/TableHelpers.js +15 -6
  102. package/dist/Helpers/TableJoins.d.ts +70 -0
  103. package/dist/Helpers/TableJoins.js +250 -0
  104. package/dist/Helpers/TableStore.d.ts +16 -3
  105. package/dist/Helpers/TableStore.js +25 -5
  106. package/dist/Helpers/ThemeHelpers.d.ts +21 -4
  107. package/dist/Helpers/ThemeHelpers.js +46 -9
  108. package/dist/Helpers/TreeStore.d.ts +225 -11
  109. package/dist/Helpers/TreeStore.js +683 -90
  110. package/dist/Helpers/UserPreferences.d.ts +22 -0
  111. package/dist/Helpers/UserPreferences.js +116 -0
  112. package/dist/Interfaces/CrudInterfaces.d.ts +67 -10
  113. package/dist/Interfaces/ExcelInterfaces.d.ts +12 -0
  114. package/dist/Interfaces/FormInterfaces.d.ts +37 -6
  115. package/dist/Interfaces/InputInterfaces.d.ts +24 -0
  116. package/dist/Interfaces/MessageInterfaces.d.ts +1 -1
  117. package/dist/Interfaces/PreferenceInterfaces.d.ts +51 -0
  118. package/dist/Interfaces/PreferenceInterfaces.js +25 -0
  119. package/dist/Interfaces/TableInterfaces.d.ts +145 -16
  120. package/dist/Interfaces/ThemeInterfaces.d.ts +25 -0
  121. package/dist/Interfaces/ThemeInterfaces.js +15 -0
  122. package/dist/Services/ApiProxy.d.ts +36 -1
  123. package/dist/Services/ApiProxy.js +182 -52
  124. package/dist/Services/ApiProxyRules.d.ts +203 -0
  125. package/dist/Services/ApiProxyRules.js +426 -0
  126. package/dist/Services/ExcelApi.d.ts +3 -1
  127. package/dist/Services/ExcelApi.js +1 -1
  128. package/dist/Services/JoinSources.d.ts +48 -0
  129. package/dist/Services/JoinSources.js +74 -0
  130. package/dist/Services/ServerApi.d.ts +13 -2
  131. package/dist/Services/ServerApi.js +8 -3
  132. package/dist/Services/ThemeService.d.ts +18 -2
  133. package/dist/Services/ThemeService.js +80 -16
  134. package/dist/Shared.Index.d.ts +6 -0
  135. package/dist/Shared.Index.js +6 -0
  136. package/package.json +4 -2
  137. package/src/Styles/Nexus.Avatar.css +79 -0
  138. package/src/Styles/Nexus.Base.css +51 -4
  139. package/src/Styles/Nexus.Button.css +27 -0
  140. package/src/Styles/Nexus.Calendar.css +4 -6
  141. package/src/Styles/Nexus.Chat.css +5 -51
  142. package/src/Styles/Nexus.Choice.css +4 -6
  143. package/src/Styles/Nexus.Crud.css +16 -9
  144. package/src/Styles/Nexus.Dropdown.css +12 -61
  145. package/src/Styles/Nexus.Editor.css +4 -6
  146. package/src/Styles/Nexus.Excel.css +34 -15
  147. package/src/Styles/Nexus.Form.css +25 -67
  148. package/src/Styles/Nexus.Index.css +1 -0
  149. package/src/Styles/Nexus.Input.css +794 -637
  150. package/src/Styles/Nexus.Markdown.css +0 -28
  151. package/src/Styles/Nexus.Menu.css +8 -30
  152. package/src/Styles/Nexus.Popup.css +22 -29
  153. package/src/Styles/Nexus.RichText.css +2 -32
  154. package/src/Styles/Nexus.Rows.css +51 -10
  155. package/src/Styles/Nexus.Slider.css +3 -5
  156. package/src/Styles/Nexus.Tab.Buttons.css +20 -7
  157. package/src/Styles/Nexus.Table.css +1387 -1296
  158. package/src/Styles/Nexus.Theme.Picker.css +342 -232
  159. package/src/Styles/Nexus.Time.css +9 -12
  160. package/src/Styles/Nexus.Toast.css +6 -8
  161. package/src/Styles/Nexus.Tokens.css +17 -0
  162. package/src/Styles/Nexus.Tree.css +76 -11
@@ -0,0 +1,426 @@
1
+ /*
2
+ * The rules the API proxy keeps so that the browser never learns where the backend is or what it runs: which request
3
+ * headers go on, which response headers come back, which redirects the proxy follows itself, and which error answers it
4
+ * replaces. Plain functions over the standard Headers, URL and streams, so they run in Node, the Edge runtime and the
5
+ * unit tests alike. `ApiProxy.ts` puts them together.
6
+ */
7
+ /** The most bytes a request body may have, unless the app says otherwise (`maxBodySize`): 100 MB. */
8
+ export const DEFAULT_PROXY_BODY_LIMIT = 100 * 1024 * 1024;
9
+ /** How many redirects inside a module the proxy follows for one call, unless the app says otherwise (`maxRedirects`). */
10
+ export const DEFAULT_PROXY_REDIRECTS = 3;
11
+ /** The most of an error answer the proxy reads to see what it is. A bigger one is never passed on. */
12
+ export const ERROR_BODY_LIMIT = 1024 * 1024;
13
+ // ---------------------------------------------------------------------------------------------------------------------
14
+ // Request headers
15
+ /** Request headers that never reach the backend. */
16
+ const REQUEST_HEADERS_DROPPED = new Set([
17
+ // This hop's own, and what `fetch` sets by itself. `expect` would make Node's fetch throw.
18
+ "host",
19
+ "connection",
20
+ "keep-alive",
21
+ "proxy-connection",
22
+ "transfer-encoding",
23
+ "te",
24
+ "trailer",
25
+ "upgrade",
26
+ "expect",
27
+ "content-length",
28
+ "accept-encoding",
29
+ "proxy-authorization",
30
+ // The app's session stays with the app; the user's token goes instead.
31
+ "cookie",
32
+ // Where the request came from, as the browser tells it: anyone can send these, so the proxy writes its own.
33
+ "forwarded",
34
+ "via",
35
+ "x-real-ip",
36
+ "x-client-ip",
37
+ "true-client-ip",
38
+ "cf-connecting-ip",
39
+ "fastly-client-ip",
40
+ "x-cluster-client-ip",
41
+ "x-original-forwarded-for",
42
+ // Headers some servers obey to change the method or the path after the proxy has checked both.
43
+ "x-http-method-override",
44
+ "x-http-method",
45
+ "x-method-override",
46
+ "x-original-url",
47
+ "x-rewrite-url",
48
+ // Next.js's own.
49
+ "rsc",
50
+ ]);
51
+ /** Prefixes of request headers that never reach the backend: every X-Forwarded-* (the proxy writes its own), and Next.js's. */
52
+ const REQUEST_PREFIXES_DROPPED = ["x-forwarded-", "x-middleware-", "x-invoke-", "x-nextjs-", "next-"];
53
+ /**
54
+ * The browser's request headers the backend may see: all of them except the ones above, and except any that the
55
+ * request's own `Connection` header names (those belong to this hop too).
56
+ */
57
+ export function forwardRequestHeaders(incoming) {
58
+ const named = (incoming.get("connection") ?? "")
59
+ .split(",")
60
+ .map(name => name.trim().toLowerCase())
61
+ .filter(name => name !== "");
62
+ const headers = new Headers();
63
+ incoming.forEach((value, name) => {
64
+ const lower = name.toLowerCase();
65
+ if (REQUEST_HEADERS_DROPPED.has(lower) || named.includes(lower))
66
+ return;
67
+ if (REQUEST_PREFIXES_DROPPED.some(prefix => lower.startsWith(prefix)))
68
+ return;
69
+ headers.set(name, value);
70
+ });
71
+ return headers;
72
+ }
73
+ const IPV4 = /^(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}$/;
74
+ /** The only characters an IPv6 address is written with: hex digits, colons, and the dots of an IPv4 address at its end. */
75
+ const IPV6_CHARACTERS = /^[0-9a-f:.]+$/i;
76
+ /**
77
+ * An IP address as text, without the port some proxies add; `undefined` for anything else (a host name, "unknown", an
78
+ * obfuscated identifier, a zone such as `%eth0`). An IPv6 address is checked by the URL parser, which every runtime has,
79
+ * once its characters are known to be an address's: without that, text that closes the bracket itself and goes on
80
+ * (`::1]/,6.6.6.6`) parsed as an address followed by a path.
81
+ *
82
+ * @example
83
+ * readIpAddress(" 203.0.113.7:51000 ") // "203.0.113.7"
84
+ * readIpAddress("[2001:db8::1]:443") // "2001:db8::1"
85
+ * readIpAddress("unknown") // undefined
86
+ * readIpAddress("::1]/,6.6.6.6") // undefined
87
+ */
88
+ export function readIpAddress(value) {
89
+ let text = value?.trim();
90
+ if (!text)
91
+ return undefined;
92
+ const bracketed = /^\[([^\]]+)\](:\d+)?$/.exec(text);
93
+ if (bracketed)
94
+ text = bracketed[1];
95
+ else if (/^[\d.]+:\d+$/.test(text))
96
+ text = text.slice(0, text.lastIndexOf(":"));
97
+ if (IPV4.test(text))
98
+ return text;
99
+ if (!text.includes(":") || !IPV6_CHARACTERS.test(text))
100
+ return undefined;
101
+ try {
102
+ new URL(`http://[${text}]/`);
103
+ return text;
104
+ }
105
+ catch {
106
+ return undefined;
107
+ }
108
+ }
109
+ /**
110
+ * This server's own origin, for a module whose root is a path on the app (a pretend backend): the host and port of the
111
+ * request URL, over http. Next.js builds that URL from the host and port it listens on, never from the Host header, but
112
+ * takes its protocol from X-Forwarded-Proto, which anyone can send, so the protocol is not read from it. A server that
113
+ * listens on every address (`0.0.0.0`, `::`) is called on this machine's own, and the port is always the one Next.js
114
+ * wrote, even when the URL leaves it out as its protocol's default.
115
+ *
116
+ * Never the app's configured origin: an app may build that from a request's Host header, and then a client would choose
117
+ * which port on this machine the proxy calls, with the user's token.
118
+ *
119
+ * @example
120
+ * ownOrigin(new URL("https://localhost:20016/api/app/drive/File/List")) // "http://localhost:20016"
121
+ * ownOrigin(new URL("http://0.0.0.0:3000/api/app/drive/File/List")) // "http://127.0.0.1:3000"
122
+ */
123
+ export function ownOrigin(requestUrl) {
124
+ let host = requestUrl.hostname;
125
+ if (host === "0.0.0.0")
126
+ host = "127.0.0.1";
127
+ else if (host === "[::]")
128
+ host = "[::1]";
129
+ let port = requestUrl.port;
130
+ if (port === "")
131
+ port = requestUrl.protocol === "https:" ? "443" : "80";
132
+ return `http://${host}:${port}`;
133
+ }
134
+ /**
135
+ * The client's address as the app received it: the **last** one in X-Forwarded-For, which is the one Next.js (from the
136
+ * connection) or the app's own reverse proxy in front of it wrote. The ones before it are what the client claims, and
137
+ * a client can claim anything. Behind two proxies of the app's own, or a CDN with a header of its own, the app passes
138
+ * `clientAddress` to the proxy instead. It reads anything with a `get`: a request's headers, or Next.js's `headers()`.
139
+ *
140
+ * @example
141
+ * // A browser sent "X-Forwarded-For: 6.6.6.6"; the app's nginx appended the address it really came from.
142
+ * lastForwardedAddress(new Headers({ "x-forwarded-for": "6.6.6.6, 203.0.113.7" })) // "203.0.113.7"
143
+ */
144
+ export function lastForwardedAddress(headers) {
145
+ const list = headers.get("x-forwarded-for");
146
+ if (!list)
147
+ return undefined;
148
+ const parts = list.split(",");
149
+ return readIpAddress(parts[parts.length - 1]);
150
+ }
151
+ /**
152
+ * Whether a browser sent this request from a page of another site, other than by following a link. Every current
153
+ * browser says where a request comes from in `Sec-Fetch-Site` and `Sec-Fetch-Mode`. The proxy turns the app's own
154
+ * session into the user's token, so a page on another site must not be able to call the backend through it (cross-site
155
+ * request forgery). A link followed from another site (an email, a chat) is a GET navigation and passes; a request
156
+ * without these headers (an older browser, a server, a tool) is no browser's cross-site call and passes too.
157
+ */
158
+ export function isCrossSiteCall(headers, method) {
159
+ if (headers.get("sec-fetch-site") !== "cross-site")
160
+ return false;
161
+ const followedLink = headers.get("sec-fetch-mode") === "navigate" && (method === "GET" || method === "HEAD");
162
+ return !followedLink;
163
+ }
164
+ /** A path segment the proxy refuses: one that climbs (`.`, `..`, nothing but dots and spaces), or one holding a slash. */
165
+ export function isUnsafeSegment(segment) {
166
+ return /^[\s.]*$/.test(segment) || /[\\/]/.test(segment);
167
+ }
168
+ /** Reads a body whole, up to `limit` bytes. `null` when it is longer: the rest is not read. */
169
+ export async function readBody(stream, limit) {
170
+ if (!stream)
171
+ return new Uint8Array(0);
172
+ const reader = stream.getReader();
173
+ const chunks = [];
174
+ let size = 0;
175
+ for (;;) {
176
+ const { done, value } = await reader.read();
177
+ if (done)
178
+ break;
179
+ size += value.byteLength;
180
+ if (size > limit) {
181
+ await reader.cancel().catch(() => undefined);
182
+ return null;
183
+ }
184
+ chunks.push(value);
185
+ }
186
+ const whole = new Uint8Array(size);
187
+ let at = 0;
188
+ for (const chunk of chunks) {
189
+ whole.set(chunk, at);
190
+ at += chunk.byteLength;
191
+ }
192
+ return whole;
193
+ }
194
+ // ---------------------------------------------------------------------------------------------------------------------
195
+ // Response headers
196
+ /** The backend's response headers that come back to the browser, and the only ones unless an app names more. */
197
+ export const DEFAULT_PROXY_RESPONSE_HEADERS = [
198
+ "content-type",
199
+ "content-disposition",
200
+ "content-language",
201
+ "content-range",
202
+ "accept-ranges",
203
+ "cache-control",
204
+ "expires",
205
+ "etag",
206
+ "last-modified",
207
+ "vary",
208
+ "retry-after",
209
+ ];
210
+ /** Prefixes that come back too: the Nexus backend's own headers for the client, such as a download's X-Nexus-Export-Rows. */
211
+ const RESPONSE_PREFIXES_KEPT = ["x-nexus-"];
212
+ /**
213
+ * Response headers that never come back, even when an app names them. They name the backend or its software, point at
214
+ * it (Location, Link, Refresh), carry its own policies for its own origin, or belong to this hop. CORS headers are here
215
+ * because on the app's origin they would let another site read a user's data through the proxy, with the user's token.
216
+ */
217
+ const RESPONSE_HEADERS_NEVER = new Set([
218
+ "server",
219
+ "x-sourcefiles",
220
+ "via",
221
+ "alt-svc",
222
+ "strict-transport-security",
223
+ "report-to",
224
+ "reporting-endpoints",
225
+ "nel",
226
+ "expect-ct",
227
+ "public-key-pins",
228
+ "public-key-pins-report-only",
229
+ "location",
230
+ "content-location",
231
+ "link",
232
+ "refresh",
233
+ "www-authenticate",
234
+ "timing-allow-origin",
235
+ "connection",
236
+ "keep-alive",
237
+ "transfer-encoding",
238
+ "te",
239
+ "trailer",
240
+ "upgrade",
241
+ // `fetch` has already decoded the body, so its encoding and length no longer describe what the browser gets.
242
+ "content-encoding",
243
+ "content-length",
244
+ // Only with `passCookies`, and then read on its own (see below).
245
+ "set-cookie",
246
+ ]);
247
+ /** Prefixes of response headers that never come back: X-AspNet-Version, X-AspNetMvc-Version, X-Powered-By*, CORS, Proxy-*. */
248
+ const RESPONSE_PREFIXES_NEVER = ["x-aspnet", "x-powered-by", "access-control-", "proxy-"];
249
+ /** Whether a response header (its lower-case name) may reach the browser. */
250
+ function mayPass(name, allowed) {
251
+ if (RESPONSE_HEADERS_NEVER.has(name))
252
+ return false;
253
+ if (RESPONSE_PREFIXES_NEVER.some(prefix => name.startsWith(prefix)))
254
+ return false;
255
+ return allowed.has(name) || RESPONSE_PREFIXES_KEPT.some(prefix => name.startsWith(prefix));
256
+ }
257
+ /** The backend's response headers the browser may see: the allowlist and the app's own names, never one of the above. */
258
+ export function filterResponseHeaders(answer, rules = {}) {
259
+ const allowed = new Set([...DEFAULT_PROXY_RESPONSE_HEADERS, ...(rules.extra ?? [])].map(name => name.toLowerCase()));
260
+ const headers = new Headers();
261
+ answer.forEach((value, name) => {
262
+ const lower = name.toLowerCase();
263
+ if (mayPass(lower, allowed))
264
+ headers.append(lower, value);
265
+ });
266
+ // Several cookies cannot be joined into one line, so they are read one by one.
267
+ if (rules.passCookies)
268
+ for (const cookie of answer.getSetCookie())
269
+ headers.append("set-cookie", cookie);
270
+ return headers;
271
+ }
272
+ // ---------------------------------------------------------------------------------------------------------------------
273
+ // Redirects
274
+ /** The statuses `fetch` follows as redirects. The proxy follows each one itself or refuses it; none reaches the browser. */
275
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
276
+ export function isRedirect(status) {
277
+ return REDIRECT_STATUSES.has(status);
278
+ }
279
+ /**
280
+ * Where a backend's redirect leads, when it stays inside the module: on the root's own origin, or on the root's own
281
+ * host moved from http to https (a backend's HTTPS redirection, which picks its own https port), and under the root's
282
+ * path, whose casing may differ ("/App/Api" and "/app/api"). Anything else - another host or port, a move from https
283
+ * to http, a path outside the root, a URL with a user name in it - is `undefined`, and the proxy refuses it: following
284
+ * it would carry the user's token somewhere the app never named.
285
+ *
286
+ * @example
287
+ * // The module's root is http://localhost:10040/App/Api, and the proxy asked for .../App/Api/Region/ActiveListing:
288
+ * redirectInsideModule("https://localhost:10041/App/Api/Region/ActiveListing", asked, root) // followed: the https upgrade
289
+ * redirectInsideModule("/App/Api/Region/ActiveListing/", asked, root) // followed: a trailing slash
290
+ * redirectInsideModule("https://sso.example.com/login?next=/App/Api", asked, root) // undefined: another host
291
+ */
292
+ export function redirectInsideModule(location, from, root) {
293
+ let target;
294
+ try {
295
+ target = new URL(location, from);
296
+ }
297
+ catch {
298
+ return undefined;
299
+ }
300
+ if (target.username !== "" || target.password !== "")
301
+ return undefined;
302
+ const sameOrigin = target.origin === root.origin;
303
+ const upgraded = root.protocol === "http:" && target.protocol === "https:" && target.hostname === root.hostname;
304
+ if (!sameOrigin && !upgraded)
305
+ return undefined;
306
+ const base = root.pathname.replace(/\/+$/, "").toLowerCase();
307
+ const path = target.pathname.toLowerCase();
308
+ if (base !== "" && path !== base && !path.startsWith(`${base}/`))
309
+ return undefined;
310
+ target.hash = "";
311
+ return target;
312
+ }
313
+ /**
314
+ * The method of the request after a redirect, as the Fetch standard follows one: a 303 asks for a GET (a HEAD stays a
315
+ * HEAD), a 301 or 302 turns a POST into a GET, and a 307 or 308 keeps the method and the body.
316
+ */
317
+ export function methodAfterRedirect(status, method) {
318
+ if (status === 303 && method !== "GET" && method !== "HEAD")
319
+ return "GET";
320
+ if ((status === 301 || status === 302) && method === "POST")
321
+ return "GET";
322
+ return method;
323
+ }
324
+ /** Headers that describe a request's body: they go with the body when a redirect turns the request into a GET. */
325
+ export const BODY_HEADERS = ["content-type", "content-language", "content-encoding", "content-location"];
326
+ // ---------------------------------------------------------------------------------------------------------------------
327
+ // Error answers
328
+ /** Top-level keys of a JSON answer that is a stack trace: ASP.NET Core's developer exception page, a serialized exception. */
329
+ const TRACE_KEYS = new Set(["exception", "innerexception", "stacktrace", "stacktracestring", "stack"]);
330
+ /**
331
+ * Why an error answer cannot reach the browser as it came, or `undefined` when it can. An empty answer passes (there is
332
+ * nothing in it), and so does JSON - the backend's envelope, or problem details - unless it is a stack trace or repeats
333
+ * the user's token (a page that lists the request's headers). Anything else (an HTML page, text, XML) is the backend
334
+ * talking about itself.
335
+ */
336
+ export function whyHidden(contentType, text, token) {
337
+ if (text.trim() === "")
338
+ return undefined;
339
+ if (token && text.includes(token))
340
+ return "it repeated the user's token";
341
+ if (!contentType.toLowerCase().includes("json"))
342
+ return `it was ${contentType.split(";")[0].trim() || "a body with no type"}, not JSON`;
343
+ let parsed;
344
+ try {
345
+ parsed = JSON.parse(text);
346
+ }
347
+ catch {
348
+ return "it said it was JSON and was not";
349
+ }
350
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
351
+ if (Object.keys(parsed).some(key => TRACE_KEYS.has(key.toLowerCase())))
352
+ return "it was a stack trace";
353
+ }
354
+ return undefined;
355
+ }
356
+ /**
357
+ * A short, one-line piece of an error answer for the server's log: the user's token, styles, scripts and tags taken
358
+ * out, spaces collapsed.
359
+ */
360
+ export function logSnippet(text, token, length = 300) {
361
+ let plain = token ? text.split(token).join("[token]") : text;
362
+ plain = plain
363
+ .replace(/<(style|script)\b[\s\S]*?<\/\1>/gi, " ")
364
+ .replace(/<[^>]*>/g, " ")
365
+ .replace(/\s+/g, " ")
366
+ .trim();
367
+ return plain.length > length ? `${plain.slice(0, length)}...` : plain;
368
+ }
369
+ /** A URL for the server's log: its origin and path, never its query (it may hold what a user searched for, or a key). */
370
+ export function logUrl(url) {
371
+ return `${url.origin}${url.pathname}`;
372
+ }
373
+ // ---------------------------------------------------------------------------------------------------------------------
374
+ // The proxy's own answers
375
+ /**
376
+ * The proxy's own refusals: each code is answered from one place in `ApiProxy.ts`, with its HTTP status and its kind
377
+ * as the Nexus backend numbers kinds (`EErrorCodes`, where a kind's category is its number divided by 20).
378
+ */
379
+ export const PROXY_REFUSALS = {
380
+ /** A method other than GET, HEAD, POST, PUT, PATCH and DELETE. RequestInvalid. */
381
+ METHOD_NOT_ALLOWED: { status: 405, errorCode: 29 },
382
+ /** A call from a page of another site. AccessDenied. */
383
+ CROSS_SITE_REFUSED: { status: 403, errorCode: 60 },
384
+ /** A module the app does not have. RequestInvalid. */
385
+ MODULE_NOT_FOUND: { status: 404, errorCode: 29 },
386
+ /** A module the browser calls directly, not through the proxy. RequestInvalid. */
387
+ MODULE_NOT_PROXIED: { status: 404, errorCode: 29 },
388
+ /** A path that climbs out of its module. RequestInvalid. */
389
+ INVALID_PATH: { status: 400, errorCode: 29 },
390
+ /** A body over the limit. TooLong. */
391
+ REQUEST_TOO_LARGE: { status: 413, errorCode: 22 },
392
+ /** A body that could not be read (the client left while sending it). RequestInvalid. */
393
+ REQUEST_UNREADABLE: { status: 400, errorCode: 29 },
394
+ /** A module without a usable root URL on this server. SettingMissing. */
395
+ MODULE_NOT_CONFIGURED: { status: 500, errorCode: 120 },
396
+ /** A backend that cannot be reached: down, refused, or a certificate this server does not trust. ServiceUnavailable. */
397
+ BACKEND_UNREACHABLE: { status: 502, errorCode: 160 },
398
+ /** A backend that did not start answering in time. ServiceTimeout. */
399
+ BACKEND_TIMEOUT: { status: 504, errorCode: 161 },
400
+ /** A redirect out of the module, or one with no usable Location. ServiceAnswerInvalid. */
401
+ BACKEND_REDIRECT_REFUSED: { status: 502, errorCode: 163 },
402
+ /** More redirects inside the module than the limit. ServiceAnswerInvalid. */
403
+ BACKEND_REDIRECT_LIMIT: { status: 502, errorCode: 163 },
404
+ /** An error page that cannot be passed on, answered with the backend's own status. ServiceRefused. */
405
+ BACKEND_ERROR_PAGE: { status: 502, errorCode: 162 },
406
+ };
407
+ /**
408
+ * A refusal as the Nexus backend answers one: `{ isSuccess: false, code, errorMessages: [{ message, messageCode,
409
+ * errorCode }] }`, with the one problem at the top level too, for readers of the older shape.
410
+ */
411
+ export function nexusFailure(failure) {
412
+ const { status, message, messageCode, errorCode } = failure;
413
+ const problem = { message, messageCode, errorCode };
414
+ return Response.json({ isSuccess: false, code: status, ...problem, errorMessages: [problem] }, { status });
415
+ }
416
+ /**
417
+ * Every answer the proxy gives: never sniffed into another type, and kept out of every cache unless the backend said
418
+ * how to cache it - the answer was fetched with the user's token, so a shared cache must never keep it for another user.
419
+ */
420
+ export function harden(response) {
421
+ const headers = new Headers(response.headers);
422
+ headers.set("X-Content-Type-Options", "nosniff");
423
+ if (!headers.has("cache-control"))
424
+ headers.set("Cache-Control", "private, no-store");
425
+ return new Response(response.body, { status: response.status, headers });
426
+ }
@@ -43,4 +43,6 @@ export declare function verifyExcelFile(endpoints: ExcelEndpoints, file: File, o
43
43
  * they made on screen would be lost. Every check runs again on the way in: what the browser sends is a row, not a
44
44
  * decision.
45
45
  */
46
- export declare function importExcelRows(endpoints: ExcelEndpoints, rows: readonly ExcelSheetRow[], options?: ExcelCallOptions): Promise<ApiResult<ExcelImportAnswer>>;
46
+ export declare function importExcelRows(endpoints: ExcelEndpoints, rows: readonly ExcelSheetRow[], options?: ExcelCallOptions & {
47
+ recordStatus?: number;
48
+ }): Promise<ApiResult<ExcelImportAnswer>>;
@@ -104,7 +104,7 @@ export async function verifyExcelFile(endpoints, file, options = {}) {
104
104
  */
105
105
  export async function importExcelRows(endpoints, rows, options = {}) {
106
106
  const backend = getExcelBackend();
107
- const answer = await api.post(endpoints.import, backend.importRequest(rows), { ...call(options), count: rows.length, toast: options.toast === false ? false : { success: false } });
107
+ const answer = await api.post(endpoints.import, backend.importRequest(rows, options.recordStatus), { ...call(options), count: rows.length, toast: options.toast === false ? false : { success: false } });
108
108
  // An import that wrote nothing at all is the one failure; it still answers with what it refused, where it says so.
109
109
  return { ...answer, result: answer.result === undefined ? undefined : backend.readImport(answer.result) };
110
110
  }
@@ -0,0 +1,48 @@
1
+ import { type CrudBackend } from "../Helpers/CrudBackend.ts";
2
+ import type { ApiEndpoint } from "../Interfaces/ApiInterfaces.ts";
3
+ import type { DropdownPage, DropdownPageRequest, OptionValue } from "../Interfaces/InputInterfaces.ts";
4
+ import type { TableJoinSource, TableJoinedRecord } from "../Interfaces/TableInterfaces.ts";
5
+ /**
6
+ * A join's source as the table calls it. An endpoint becomes one that POSTs the keys to it as an array and reads the
7
+ * records it answers - a Nexus controller's `findListing` - made once per endpoint, so every table and picker naming it
8
+ * shares one source and so one cache. A source of the page's own is used as it is.
9
+ */
10
+ export declare function joinSource(source: ApiEndpoint | TableJoinSource, backend?: CrudBackend): TableJoinSource;
11
+ /** The controller a picker offers records from: any with an `activeListing` and a `findListing` (`MODULES.controller(...)`). */
12
+ export interface PickerController {
13
+ activeListing: ApiEndpoint;
14
+ findListing: ApiEndpoint;
15
+ }
16
+ /** What `crudPicker` needs to know of the records: the field a person reads, and the one under it. */
17
+ export interface CrudPickerOptions {
18
+ /** The field shown as the option: `"regionName"`. */
19
+ labelKey: string;
20
+ /** A field shown under it, such as the parent's name: `"regionName"` under a sub-region. */
21
+ descriptionKey?: string;
22
+ /** The field that holds the key. Default `id`. */
23
+ valueKey?: string;
24
+ backend?: CrudBackend;
25
+ }
26
+ /** The dropdown params `crudPicker` gives: spread them into a dropdown's input. */
27
+ export interface CrudPickerParams {
28
+ loadOptions: (request: DropdownPageRequest) => Promise<DropdownPage<TableJoinedRecord>>;
29
+ resolveOptions: (values: OptionValue[]) => Promise<readonly TableJoinedRecord[]>;
30
+ valueKey: string;
31
+ labelKey: string;
32
+ descriptionKey?: string;
33
+ isOptionInactive: (item: TableJoinedRecord) => boolean;
34
+ }
35
+ /**
36
+ * A dropdown of a controller's usable records, for picking a parent in a form: the `activeListing` read once and kept
37
+ * for every form on the page (the owner's caching rule: a second dropdown, or an Add row, never asks again), searched
38
+ * and paged in the browser. A value the form already holds that the list does not offer - a parent since trashed - is
39
+ * found through `findListing`, sharing the calls and answers of the tables' joins, and shows tagged, never offered again.
40
+ *
41
+ * @example { type: "dropdown", name: "regionId", label: "Region", required: true, ...crudPicker(REGIONS, { labelKey: "regionName" }) }
42
+ */
43
+ export declare function crudPicker(controller: PickerController, { labelKey, descriptionKey, valueKey, backend }: CrudPickerOptions): CrudPickerParams;
44
+ /**
45
+ * Marks what the page looked up from a controller as old, after a write there may have changed it: the records its
46
+ * `findListing` named, and the list its picker offers. Drawn as they are until the next ask brings the new ones.
47
+ */
48
+ export declare function expireLookups(controller: Partial<PickerController>): void;
@@ -0,0 +1,74 @@
1
+ import { isApiEndpoint } from "../Helpers/ApiRoutes.js";
2
+ import { getCrudBackend } from "../Helpers/CrudBackend.js";
3
+ import { filterItems } from "../Helpers/DropdownHelpers.js";
4
+ import { joinSourceName, tableJoins } from "../Helpers/TableJoins.js";
5
+ import { api } from "./BrowserApi.js";
6
+ /*
7
+ * The browser's half of a join: the call that looks records up, and a picker that shares what it looked up. The cache
8
+ * itself (`tableJoins`) knows nothing of the API; this is where an endpoint becomes a source it can call.
9
+ */
10
+ const made = new Map();
11
+ /**
12
+ * A join's source as the table calls it. An endpoint becomes one that POSTs the keys to it as an array and reads the
13
+ * records it answers - a Nexus controller's `findListing` - made once per endpoint, so every table and picker naming it
14
+ * shares one source and so one cache. A source of the page's own is used as it is.
15
+ */
16
+ export function joinSource(source, backend = getCrudBackend()) {
17
+ if (!isApiEndpoint(source))
18
+ return source;
19
+ const name = joinSourceName(source);
20
+ let found = made.get(name);
21
+ if (!found) {
22
+ found = {
23
+ name,
24
+ async load(keys, signal) {
25
+ const answer = await api.send(source, { body: keys, signal, toast: false });
26
+ if (!answer.ok)
27
+ throw new Error(answer.message ?? "The records could not be looked up.");
28
+ return backend.readList(answer.result);
29
+ },
30
+ };
31
+ made.set(name, found);
32
+ }
33
+ return found;
34
+ }
35
+ /**
36
+ * A dropdown of a controller's usable records, for picking a parent in a form: the `activeListing` read once and kept
37
+ * for every form on the page (the owner's caching rule: a second dropdown, or an Add row, never asks again), searched
38
+ * and paged in the browser. A value the form already holds that the list does not offer - a parent since trashed - is
39
+ * found through `findListing`, sharing the calls and answers of the tables' joins, and shows tagged, never offered again.
40
+ *
41
+ * @example { type: "dropdown", name: "regionId", label: "Region", required: true, ...crudPicker(REGIONS, { labelKey: "regionName" }) }
42
+ */
43
+ export function crudPicker(controller, { labelKey, descriptionKey, valueKey = "id", backend = getCrudBackend() }) {
44
+ const listName = joinSourceName(controller.activeListing);
45
+ // The words a person reads, not the key: a Nexus key is an encrypted token, and a search would find letters in it.
46
+ const searchKeys = descriptionKey ? [labelKey, descriptionKey] : [labelKey];
47
+ const loadList = (signal) => api.send(controller.activeListing, { signal, toast: false }).then(answer => {
48
+ if (!answer.ok)
49
+ throw new Error(answer.message ?? "The list could not be loaded.");
50
+ return backend.readList(answer.result);
51
+ });
52
+ return {
53
+ async loadOptions({ search, skip, pageSize }) {
54
+ const all = await tableJoins.list(listName, loadList);
55
+ const matches = search ? filterItems(all, search, searchKeys) : all;
56
+ return { items: matches.slice(skip, skip + pageSize), total: matches.length, hasMore: skip + pageSize < matches.length };
57
+ },
58
+ resolveOptions: values => tableJoins.records(joinSource(controller.findListing, backend), values.map(String)),
59
+ valueKey,
60
+ labelKey,
61
+ ...(descriptionKey && { descriptionKey }),
62
+ isOptionInactive: item => !backend.isPickable(item),
63
+ };
64
+ }
65
+ /**
66
+ * Marks what the page looked up from a controller as old, after a write there may have changed it: the records its
67
+ * `findListing` named, and the list its picker offers. Drawn as they are until the next ask brings the new ones.
68
+ */
69
+ export function expireLookups(controller) {
70
+ if (controller.findListing)
71
+ tableJoins.expire(joinSourceName(controller.findListing));
72
+ if (controller.activeListing)
73
+ tableJoins.expireList(joinSourceName(controller.activeListing));
74
+ }
@@ -11,7 +11,12 @@ export interface ServerApiOptions {
11
11
  }
12
12
  /** How server calls behave. Where they go (each module's root) is the app's `ApiModules`. */
13
13
  export interface ServerApiConfig {
14
- /** The app's own address, for relative roots and URLs such as `/api/…`. Default: `URL_CURRENT` from the environment. */
14
+ /**
15
+ * The app's own address, for `serverApi`'s relative roots and URLs such as `/api/…`, and for the X-Forwarded-Host and
16
+ * -Proto the proxy sends. Default: `URL_CURRENT` from the environment. Never a request's Host header as it came: a
17
+ * client can name any address in it, and `serverApi` would call that address. (The proxy calls a relative root at
18
+ * this server's own host and port, whatever this says.)
19
+ */
15
20
  origin?: string | (() => string | undefined | Promise<string | undefined>);
16
21
  /** The user's token for every call, such as from the session cookie. */
17
22
  token?: () => string | null | undefined | Promise<string | null | undefined>;
@@ -35,9 +40,15 @@ export interface ServerApiConfig {
35
40
  * configureServerApi({ token: async () => (await cookies()).get("access_token")?.value });
36
41
  */
37
42
  export declare function configureServerApi(config: ServerApiConfig): void;
43
+ /**
44
+ * The app's own address, as `configureServerApi({ origin })` or `URL_CURRENT` gives it; `undefined` when neither does.
45
+ * The proxy tells the backend it in X-Forwarded-Host and X-Forwarded-Proto.
46
+ */
47
+ export declare function serverAppOrigin(): Promise<string | undefined>;
38
48
  /**
39
49
  * A module's absolute root URL on the server, from the app's `ApiModules`. Throws "The Reservation API address is not
40
- * set (API_RESERVATION)." when it is missing. `origin` resolves a relative root, such as the proxy's request origin.
50
+ * set (API_RESERVATION)." when it is missing. `origin` resolves a relative root (the proxy passes this server's own);
51
+ * without it, the app's (`serverAppOrigin`).
41
52
  */
42
53
  export declare function serverModuleRoot(module: ApiModuleValue, origin?: string): Promise<string>;
43
54
  /**
@@ -12,21 +12,26 @@ let settings = {};
12
12
  export function configureServerApi(config) {
13
13
  settings = { ...settings, ...config };
14
14
  }
15
- async function appOrigin() {
15
+ /**
16
+ * The app's own address, as `configureServerApi({ origin })` or `URL_CURRENT` gives it; `undefined` when neither does.
17
+ * The proxy tells the backend it in X-Forwarded-Host and X-Forwarded-Proto.
18
+ */
19
+ export async function serverAppOrigin() {
16
20
  const value = typeof settings.origin === "function" ? await settings.origin() : settings.origin;
17
21
  return value || readEnvironment("URL_CURRENT");
18
22
  }
19
23
  async function absolute(url, fallbackOrigin) {
20
24
  if (/^https?:\/\//i.test(url))
21
25
  return url;
22
- const origin = fallbackOrigin ?? (await appOrigin());
26
+ const origin = fallbackOrigin ?? (await serverAppOrigin());
23
27
  if (!origin)
24
28
  throw new Error(`"${url}" is relative, and the app's address is not set (URL_CURRENT).`);
25
29
  return new URL(url.startsWith("/") ? url : `/${url}`, origin).toString();
26
30
  }
27
31
  /**
28
32
  * A module's absolute root URL on the server, from the app's `ApiModules`. Throws "The Reservation API address is not
29
- * set (API_RESERVATION)." when it is missing. `origin` resolves a relative root, such as the proxy's request origin.
33
+ * set (API_RESERVATION)." when it is missing. `origin` resolves a relative root (the proxy passes this server's own);
34
+ * without it, the app's (`serverAppOrigin`).
30
35
  */
31
36
  export async function serverModuleRoot(module, origin) {
32
37
  return absolute(await getApiModules().serverUrl(module), origin);