tempest-react-sdk 0.80.0 → 0.82.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 (247) hide show
  1. package/README.md +40 -10
  2. package/bin/create-tempest-app.e2e.test.mjs +31 -0
  3. package/bin/create-tempest-app.mjs +11 -1
  4. package/bin/lib/component/generate.test.mjs +13 -1
  5. package/bin/lib/doctor/doctor.e2e.test.mjs +29 -0
  6. package/bin/lib/doctor/vitest.mjs +120 -0
  7. package/bin/lib/doctor/vitest.test.mjs +139 -0
  8. package/bin/lib/fs/contain.mjs +7 -0
  9. package/bin/lib/openapi/escape.mjs +68 -4
  10. package/bin/lib/openapi/generate.hostile.test.mjs +150 -1
  11. package/bin/lib/openapi/generate.mjs +78 -13
  12. package/bin/lib/openapi/generate.test.mjs +13 -1
  13. package/bin/lib/openapi/pattern-risk.mjs +134 -0
  14. package/bin/lib/openapi/pattern-risk.test.mjs +45 -0
  15. package/bin/tempest-gen.test.mjs +100 -0
  16. package/bin/tempest.mjs +24 -8
  17. package/dist/access/use-can.cjs +1 -1
  18. package/dist/access/use-can.cjs.map +1 -1
  19. package/dist/access/use-can.js +25 -28
  20. package/dist/access/use-can.js.map +1 -1
  21. package/dist/auth/create-tempest-auth.cjs +1 -1
  22. package/dist/auth/create-tempest-auth.cjs.map +1 -1
  23. package/dist/auth/create-tempest-auth.js +234 -122
  24. package/dist/auth/create-tempest-auth.js.map +1 -1
  25. package/dist/auth/session-identity.cjs +2 -0
  26. package/dist/auth/session-identity.cjs.map +1 -0
  27. package/dist/auth/session-identity.js +12 -0
  28. package/dist/auth/session-identity.js.map +1 -0
  29. package/dist/br/boleto.cjs +1 -1
  30. package/dist/br/boleto.cjs.map +1 -1
  31. package/dist/br/boleto.js +2 -1
  32. package/dist/br/boleto.js.map +1 -1
  33. package/dist/br.d.ts +22 -3
  34. package/dist/components/AuthForm/AuthFieldControl.cjs +2 -0
  35. package/dist/components/AuthForm/AuthFieldControl.cjs.map +1 -0
  36. package/dist/components/AuthForm/AuthFieldControl.js +85 -0
  37. package/dist/components/AuthForm/AuthFieldControl.js.map +1 -0
  38. package/dist/components/AuthForm/AuthForm.cjs +2 -0
  39. package/dist/components/AuthForm/AuthForm.cjs.map +1 -0
  40. package/dist/components/AuthForm/AuthForm.js +83 -0
  41. package/dist/components/AuthForm/AuthForm.js.map +1 -0
  42. package/dist/components/AuthForm/AuthForm.module.cjs +3 -0
  43. package/dist/components/AuthForm/AuthForm.module.cjs.map +1 -0
  44. package/dist/components/AuthForm/AuthForm.module.js +14 -0
  45. package/dist/components/AuthForm/AuthForm.module.js.map +1 -0
  46. package/dist/components/AuthForm/AuthLinks.cjs +2 -0
  47. package/dist/components/AuthForm/AuthLinks.cjs.map +1 -0
  48. package/dist/components/AuthForm/AuthLinks.js +32 -0
  49. package/dist/components/AuthForm/AuthLinks.js.map +1 -0
  50. package/dist/components/AuthForm/ForgotPasswordForm.cjs +2 -0
  51. package/dist/components/AuthForm/ForgotPasswordForm.cjs.map +1 -0
  52. package/dist/components/AuthForm/ForgotPasswordForm.js +32 -0
  53. package/dist/components/AuthForm/ForgotPasswordForm.js.map +1 -0
  54. package/dist/components/AuthForm/LoginForm.cjs +2 -0
  55. package/dist/components/AuthForm/LoginForm.cjs.map +1 -0
  56. package/dist/components/AuthForm/LoginForm.js +39 -0
  57. package/dist/components/AuthForm/LoginForm.js.map +1 -0
  58. package/dist/components/AuthForm/ResetPasswordForm.cjs +2 -0
  59. package/dist/components/AuthForm/ResetPasswordForm.cjs.map +1 -0
  60. package/dist/components/AuthForm/ResetPasswordForm.js +39 -0
  61. package/dist/components/AuthForm/ResetPasswordForm.js.map +1 -0
  62. package/dist/components/AuthForm/SignUpForm.cjs +2 -0
  63. package/dist/components/AuthForm/SignUpForm.cjs.map +1 -0
  64. package/dist/components/AuthForm/SignUpForm.js +32 -0
  65. package/dist/components/AuthForm/SignUpForm.js.map +1 -0
  66. package/dist/components/AuthForm/auth-defaults.cjs +2 -0
  67. package/dist/components/AuthForm/auth-defaults.cjs.map +1 -0
  68. package/dist/components/AuthForm/auth-defaults.js +54 -0
  69. package/dist/components/AuthForm/auth-defaults.js.map +1 -0
  70. package/dist/components/AuthForm/auth-schemas.cjs +2 -0
  71. package/dist/components/AuthForm/auth-schemas.cjs.map +1 -0
  72. package/dist/components/AuthForm/auth-schemas.js +26 -0
  73. package/dist/components/AuthForm/auth-schemas.js.map +1 -0
  74. package/dist/components/AuthPage/AuthPage.cjs +2 -0
  75. package/dist/components/AuthPage/AuthPage.cjs.map +1 -0
  76. package/dist/components/AuthPage/AuthPage.js +97 -0
  77. package/dist/components/AuthPage/AuthPage.js.map +1 -0
  78. package/dist/components/AuthPage/AuthPage.module.cjs +3 -0
  79. package/dist/components/AuthPage/AuthPage.module.cjs.map +1 -0
  80. package/dist/components/AuthPage/AuthPage.module.js +24 -0
  81. package/dist/components/AuthPage/AuthPage.module.js.map +1 -0
  82. package/dist/components/Breadcrumbs/Breadcrumbs.cjs +1 -1
  83. package/dist/components/Breadcrumbs/Breadcrumbs.cjs.map +1 -1
  84. package/dist/components/Breadcrumbs/Breadcrumbs.js +22 -21
  85. package/dist/components/Breadcrumbs/Breadcrumbs.js.map +1 -1
  86. package/dist/components/Button/Button.cjs +1 -1
  87. package/dist/components/Button/Button.cjs.map +1 -1
  88. package/dist/components/Button/Button.js +41 -40
  89. package/dist/components/Button/Button.js.map +1 -1
  90. package/dist/components/Chat/ChatAttachments.cjs +1 -1
  91. package/dist/components/Chat/ChatAttachments.cjs.map +1 -1
  92. package/dist/components/Chat/ChatAttachments.js +38 -38
  93. package/dist/components/FilterBar/filter-apply.cjs +1 -1
  94. package/dist/components/FilterBar/filter-apply.cjs.map +1 -1
  95. package/dist/components/FilterBar/filter-apply.js +7 -7
  96. package/dist/components/Form/Form.cjs +1 -1
  97. package/dist/components/Form/Form.cjs.map +1 -1
  98. package/dist/components/Form/Form.js +30 -32
  99. package/dist/components/Form/Form.js.map +1 -1
  100. package/dist/components/Layout/Layout.cjs +1 -1
  101. package/dist/components/Layout/Layout.cjs.map +1 -1
  102. package/dist/components/Layout/Layout.js +25 -24
  103. package/dist/components/Layout/Layout.js.map +1 -1
  104. package/dist/components/Layout/gap.cjs +2 -0
  105. package/dist/components/Layout/gap.cjs.map +1 -0
  106. package/dist/components/Layout/gap.js +15 -0
  107. package/dist/components/Layout/gap.js.map +1 -0
  108. package/dist/components/NavigationMenu/NavigationMenu.cjs +1 -1
  109. package/dist/components/NavigationMenu/NavigationMenu.cjs.map +1 -1
  110. package/dist/components/NavigationMenu/NavigationMenu.js +71 -70
  111. package/dist/components/NavigationMenu/NavigationMenu.js.map +1 -1
  112. package/dist/components/Sidebar/Sidebar.cjs.map +1 -1
  113. package/dist/components/Sidebar/Sidebar.js.map +1 -1
  114. package/dist/components/Sidebar/SidebarEntry.cjs +1 -1
  115. package/dist/components/Sidebar/SidebarEntry.cjs.map +1 -1
  116. package/dist/components/Sidebar/SidebarEntry.js +30 -29
  117. package/dist/components/Sidebar/SidebarEntry.js.map +1 -1
  118. package/dist/data/create-data-provider.cjs +1 -1
  119. package/dist/data/create-data-provider.cjs.map +1 -1
  120. package/dist/data/create-data-provider.js +1 -2
  121. package/dist/data/create-data-provider.js.map +1 -1
  122. package/dist/forms/index.js +7 -7
  123. package/dist/forms/masked-inputs.cjs +1 -1
  124. package/dist/forms/masked-inputs.cjs.map +1 -1
  125. package/dist/forms/masked-inputs.js +15 -15
  126. package/dist/forms/validate-form.cjs +1 -1
  127. package/dist/forms/validate-form.cjs.map +1 -1
  128. package/dist/forms/validate-form.js +6 -1
  129. package/dist/forms/validate-form.js.map +1 -1
  130. package/dist/forms/zod-resolver.cjs +1 -1
  131. package/dist/forms/zod-resolver.cjs.map +1 -1
  132. package/dist/forms/zod-resolver.js +8 -3
  133. package/dist/forms/zod-resolver.js.map +1 -1
  134. package/dist/http/api-client.cjs +1 -1
  135. package/dist/http/api-client.cjs.map +1 -1
  136. package/dist/http/api-client.js +137 -116
  137. package/dist/http/api-client.js.map +1 -1
  138. package/dist/http/credential-scope.cjs +1 -1
  139. package/dist/http/credential-scope.cjs.map +1 -1
  140. package/dist/http/credential-scope.js +33 -9
  141. package/dist/http/credential-scope.js.map +1 -1
  142. package/dist/http/presigned-post.cjs.map +1 -1
  143. package/dist/http/presigned-post.js.map +1 -1
  144. package/dist/http/resumable-upload.cjs +1 -1
  145. package/dist/http/resumable-upload.cjs.map +1 -1
  146. package/dist/http/resumable-upload.js +140 -134
  147. package/dist/http/resumable-upload.js.map +1 -1
  148. package/dist/http/upload-with-progress.cjs +1 -1
  149. package/dist/http/upload-with-progress.cjs.map +1 -1
  150. package/dist/http/upload-with-progress.js +17 -17
  151. package/dist/http/upload-with-progress.js.map +1 -1
  152. package/dist/http/use-describe-api-error.cjs +1 -1
  153. package/dist/http/use-describe-api-error.cjs.map +1 -1
  154. package/dist/http/use-describe-api-error.js +9 -9
  155. package/dist/oauth/GoogleSignIn.cjs +1 -1
  156. package/dist/oauth/GoogleSignIn.cjs.map +1 -1
  157. package/dist/oauth/GoogleSignIn.js +5 -4
  158. package/dist/oauth/GoogleSignIn.js.map +1 -1
  159. package/dist/offline/create-offline-sync.cjs +1 -1
  160. package/dist/offline/create-offline-sync.cjs.map +1 -1
  161. package/dist/offline/create-offline-sync.js +108 -68
  162. package/dist/offline/create-offline-sync.js.map +1 -1
  163. package/dist/query/offline-persistence.cjs +1 -1
  164. package/dist/query/offline-persistence.cjs.map +1 -1
  165. package/dist/query/offline-persistence.js +54 -44
  166. package/dist/query/offline-persistence.js.map +1 -1
  167. package/dist/sse/create-event-stream.cjs +1 -1
  168. package/dist/sse/create-event-stream.cjs.map +1 -1
  169. package/dist/sse/create-event-stream.js +48 -41
  170. package/dist/sse/create-event-stream.js.map +1 -1
  171. package/dist/sse/ticket-event-stream.cjs +1 -1
  172. package/dist/sse/ticket-event-stream.cjs.map +1 -1
  173. package/dist/sse/ticket-event-stream.js +48 -19
  174. package/dist/sse/ticket-event-stream.js.map +1 -1
  175. package/dist/sse/use-event-stream.cjs +1 -1
  176. package/dist/sse/use-event-stream.cjs.map +1 -1
  177. package/dist/sse/use-event-stream.js +19 -14
  178. package/dist/sse/use-event-stream.js.map +1 -1
  179. package/dist/styles/AuthForm.css +2 -0
  180. package/dist/styles/AuthPage.css +2 -0
  181. package/dist/styles/component/AuthForm.css +9 -0
  182. package/dist/styles/component/AuthPage.css +29 -0
  183. package/dist/styles/forms.css +36 -0
  184. package/dist/styles/manifest.json +63 -0
  185. package/dist/styles.css +1 -1
  186. package/dist/sw/background-sync.cjs +1 -1
  187. package/dist/sw/background-sync.cjs.map +1 -1
  188. package/dist/sw/background-sync.js +132 -112
  189. package/dist/sw/background-sync.js.map +1 -1
  190. package/dist/sw/cache.cjs +1 -1
  191. package/dist/sw/cache.cjs.map +1 -1
  192. package/dist/sw/cache.js +36 -30
  193. package/dist/sw/cache.js.map +1 -1
  194. package/dist/sw/create-push-handler.cjs +1 -1
  195. package/dist/sw/create-push-handler.cjs.map +1 -1
  196. package/dist/sw/create-push-handler.js +20 -10
  197. package/dist/sw/create-push-handler.js.map +1 -1
  198. package/dist/sw.d.ts +93 -28
  199. package/dist/tabular/compact.cjs +1 -1
  200. package/dist/tabular/compact.cjs.map +1 -1
  201. package/dist/tabular/compact.js +35 -16
  202. package/dist/tabular/compact.js.map +1 -1
  203. package/dist/tabular/manifest.cjs +1 -1
  204. package/dist/tabular/manifest.cjs.map +1 -1
  205. package/dist/tabular/manifest.js +33 -15
  206. package/dist/tabular/manifest.js.map +1 -1
  207. package/dist/tabular.d.ts +11 -1
  208. package/dist/tempest-react-sdk.cjs +1 -1
  209. package/dist/tempest-react-sdk.d.ts +1057 -83
  210. package/dist/tempest-react-sdk.js +373 -365
  211. package/dist/theme/initial-theme.cjs +4 -4
  212. package/dist/theme/initial-theme.cjs.map +1 -1
  213. package/dist/theme/initial-theme.js +8 -5
  214. package/dist/theme/initial-theme.js.map +1 -1
  215. package/dist/utils/compressed-storage.cjs +1 -1
  216. package/dist/utils/compressed-storage.cjs.map +1 -1
  217. package/dist/utils/compressed-storage.js +13 -7
  218. package/dist/utils/compressed-storage.js.map +1 -1
  219. package/dist/utils/format.cjs +1 -1
  220. package/dist/utils/format.cjs.map +1 -1
  221. package/dist/utils/format.js +25 -25
  222. package/dist/utils/numbers.cjs +1 -1
  223. package/dist/utils/numbers.cjs.map +1 -1
  224. package/dist/utils/numbers.js +7 -7
  225. package/dist/utils/objects.cjs +1 -1
  226. package/dist/utils/objects.cjs.map +1 -1
  227. package/dist/utils/objects.js +13 -7
  228. package/dist/utils/objects.js.map +1 -1
  229. package/dist/utils/safe-url.cjs +1 -1
  230. package/dist/utils/safe-url.cjs.map +1 -1
  231. package/dist/utils/safe-url.js +4 -1
  232. package/dist/utils/safe-url.js.map +1 -1
  233. package/dist/webrtc/peer-mesh.cjs +1 -1
  234. package/dist/webrtc/peer-mesh.cjs.map +1 -1
  235. package/dist/webrtc/peer-mesh.js +7 -1
  236. package/dist/webrtc/peer-mesh.js.map +1 -1
  237. package/dist/ws/create-web-socket.cjs +1 -1
  238. package/dist/ws/create-web-socket.cjs.map +1 -1
  239. package/dist/ws/create-web-socket.js +77 -61
  240. package/dist/ws/create-web-socket.js.map +1 -1
  241. package/dist/ws/use-web-socket.cjs +1 -1
  242. package/dist/ws/use-web-socket.cjs.map +1 -1
  243. package/dist/ws/use-web-socket.js +4 -3
  244. package/dist/ws/use-web-socket.js.map +1 -1
  245. package/package.json +1 -1
  246. package/template-pwa/src/pwa.tsx +42 -0
  247. package/template-pwa/src/sw.ts +15 -10
@@ -16,7 +16,11 @@
16
16
  * {@link assertNoCollision}, never silently merged;
17
17
  * - string position → `JSON.stringify` at the call site;
18
18
  * - doc comment → {@link commentText};
19
- * - URL template literal → {@link urlTemplate}.
19
+ * - URL template literal → {@link urlTemplate};
20
+ * - the developer's terminal → {@link terminalText}.
21
+ *
22
+ * One more check covers the text that becomes a URL rather than being escaped
23
+ * into one: {@link assertSafePath}.
20
24
  */
21
25
 
22
26
  const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
@@ -150,19 +154,79 @@ export function assertNoCollision(entries, what) {
150
154
  }
151
155
  }
152
156
 
157
+ /**
158
+ * C0 and C1 control characters except tab and line feed: everything a terminal
159
+ * may read as the start of an escape sequence (`ESC`, the 8-bit `CSI` and
160
+ * `OSC`), plus `CR` and backspace, which rewrite what is already on the line.
161
+ */
162
+ const TERMINAL_CONTROLS = /[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/g;
163
+
164
+ /**
165
+ * Make spec text safe to print to the developer's terminal.
166
+ *
167
+ * `tempest gen api` echoes the spec's tag names, and its errors quote spec
168
+ * names. `JSON.stringify` escapes the C0 range but leaves the C1 range raw, so
169
+ * neither path was safe on its own: a tag carrying `ESC ] 0 ; ... BEL` retitled
170
+ * the terminal window, and an OSC 8 sequence printed a clickable link whose
171
+ * target the developer never saw (#533). Every control character a terminal
172
+ * acts on is dropped; tab and line feed are kept so a multi-line error still
173
+ * reads as one.
174
+ *
175
+ * @param {unknown} text - Text that may come from the spec.
176
+ * @returns {string} The text without terminal control characters.
177
+ */
178
+ export function terminalText(text) {
179
+ return String(text).replace(TERMINAL_CONTROLS, "");
180
+ }
181
+
153
182
  /**
154
183
  * Make spec text safe inside a `/** ... *\/` block comment.
155
184
  *
156
185
  * Line terminators collapse to a space, so the text cannot start a new line of
157
186
  * the comment, and every `*\/` is broken as `*\\/` so it cannot close it.
187
+ * Terminal control characters are dropped too ({@link terminalText}): they are
188
+ * not code, but printing the generated file with `cat` would replay them.
158
189
  *
159
190
  * @param {unknown} text - The spec text (tag, summary, path).
160
191
  * @returns {string} The neutralized single-line text.
161
192
  */
162
193
  export function commentText(text) {
163
- return String(text)
164
- .replace(/[\r\n\u2028\u2029]+/g, " ")
165
- .replace(/\*\//g, "*\\/");
194
+ return terminalText(String(text).replace(/[\r\n\u2028\u2029]+/g, " ")).replace(/\*\//g, "*\\/");
195
+ }
196
+
197
+ /**
198
+ * Throw when an OpenAPI path key would not stay a path under the client's
199
+ * `baseURL`.
200
+ *
201
+ * The key becomes the URL the generated method requests, and the client passes
202
+ * an absolute URL through untouched: a key `"https://other.example/x"` made the
203
+ * method call another origin \u2014 the Bearer is withheld there, but the body and,
204
+ * with `credentials: "include"`, the cookies were not. A static `.` or `..`
205
+ * segment (`"/../x"`) climbs out of the `baseURL`'s path prefix once the URL is
206
+ * normalized. OpenAPI requires a path key to start with `/` and to be relative
207
+ * to the server URL, so neither is a valid spec; both are refused before any
208
+ * file is written instead of becoming a call the developer never reads. `\` is
209
+ * a separator and `%2e` a dot here because the WHATWG URL parser normalizes them
210
+ * that way.
211
+ *
212
+ * @param {unknown} path - The key from `paths`.
213
+ * @throws {Error} When the key does not start with `/` or has a dot segment.
214
+ */
215
+ export function assertSafePath(path) {
216
+ if (typeof path !== "string" || !path.startsWith("/")) {
217
+ throw new Error(
218
+ `OpenAPI path ${JSON.stringify(path)} does not start with "/". A path key is relative to the server URL; an absolute URL here would send the request to another origin.`,
219
+ );
220
+ }
221
+ const dotSegment = path
222
+ .replace(/[?#][\s\S]*$/, "")
223
+ .split(/[/\\]/)
224
+ .some((segment) => /^(?:\.|%2e){1,2}$/i.test(segment));
225
+ if (dotSegment) {
226
+ throw new Error(
227
+ `OpenAPI path ${JSON.stringify(path)} has a "." or ".." segment, which leaves the baseURL's path once the URL is normalized. Rename the path in the spec.`,
228
+ );
229
+ }
166
230
  }
167
231
 
168
232
  /**
@@ -4,11 +4,23 @@ import { fileURLToPath } from "node:url";
4
4
  import vm from "node:vm";
5
5
 
6
6
  import ts from "typescript";
7
- import { describe, expect, it } from "vitest";
7
+ import { describe, expect, it, vi } from "vitest";
8
8
 
9
9
  import { encodeIdSegment } from "../../../src/data";
10
10
  import { generate } from "./generate.mjs";
11
11
 
12
+ /**
13
+ * Gives the tests in this file 60 s instead of Vitest's 5 s default.
14
+ *
15
+ * Several of them build a TypeScript program (and, some, an ESLint instance)
16
+ * in-process over the generated files. That takes 3-5 s on an idle machine,
17
+ * right at the default, so a busy one — another suite, a model training — failed
18
+ * them by timeout while the code was correct: 5.1-5.7 s measured in three
19
+ * `make check` runs on 2026-11-01, load average between 21 and 48.
20
+ * `test/docs-guard.test.ts` gives its compile 120 s for the same reason.
21
+ */
22
+ vi.setConfig({ testTimeout: 60_000 });
23
+
12
24
  const require = createRequire(import.meta.url);
13
25
  /**
14
26
  * Transpile one generated TypeScript file and run it in a fresh VM context.
@@ -660,3 +672,140 @@ describe("generate — names that collide with what the generator itself declare
660
672
  );
661
673
  });
662
674
  });
675
+
676
+ describe("generate — spec text that would leave its place in the output (#533)", () => {
677
+ const ESC_TAG = "items\u001b]0;PWNED\u0007\u009b31m\r";
678
+ const POST_BODY = {
679
+ "/a": { post: { tags: ["t"], requestBody: JSON_BODY("Body"), responses: OK } },
680
+ };
681
+
682
+ it("a path key that is an absolute URL is refused, so no method calls another origin", () => {
683
+ expect(() =>
684
+ generate(spec({ "https://other.example/x": { get: { tags: ["t"], responses: OK } } })),
685
+ ).toThrow(/path "https:\/\/other\.example\/x" does not start with "\/"/);
686
+ });
687
+
688
+ it.each(["/../x", "/a/./b", "/a/..", "/%2e%2E/x", "/a\\..\\x", "/./x?y=1"])(
689
+ "a path key with a dot segment (%s) is refused, so it cannot climb out of the baseURL path",
690
+ (path) => {
691
+ expect(() =>
692
+ generate(spec({ [path]: { get: { tags: ["t"], responses: OK } } })),
693
+ ).toThrow(/has a "\." or "\.\." segment/);
694
+ },
695
+ );
696
+
697
+ it("dots inside a segment, dots in the query and a doubled slash still generate", () => {
698
+ const { files } = generate(
699
+ spec({
700
+ "/v1.2/..cache/file.json": { get: { tags: ["t"], responses: OK } },
701
+ "//double/slash": { get: { tags: ["t"], responses: OK } },
702
+ "/search?q=..": { get: { tags: ["t"], responses: OK } },
703
+ }),
704
+ );
705
+ expect(files["t/service.ts"]).toContain('"/v1.2/..cache/file.json"');
706
+ expect(files["t/service.ts"]).toContain('"/search?q=.."');
707
+ });
708
+
709
+ it("a property named __proto__ is refused instead of silently vanishing from the Zod shape", () => {
710
+ const Body = JSON.parse(
711
+ '{"type":"object","required":["__proto__"],"properties":{"__proto__":{"type":"string"}}}',
712
+ );
713
+ expect(() => generate(spec(POST_BODY, { Body }))).toThrow(
714
+ /schema "Body" at \(root\): a property named "__proto__" cannot be validated/,
715
+ );
716
+ });
717
+
718
+ it("a default value with a nested __proto__ key is refused", () => {
719
+ const Body = JSON.parse(
720
+ '{"type":"object","properties":{"meta":{"type":"object","default":{"a":{"__proto__":{"admin":true}}}}}}',
721
+ );
722
+ expect(() => generate(spec(POST_BODY, { Body }))).toThrow(
723
+ /schema "Body" at properties\.meta: the default value has a "__proto__" key/,
724
+ );
725
+ });
726
+
727
+ it("properties called constructor or prototype still generate and are validated", () => {
728
+ const { files } = generate(
729
+ spec(POST_BODY, {
730
+ Body: {
731
+ type: "object",
732
+ required: ["constructor"],
733
+ properties: {
734
+ constructor: { type: "string" },
735
+ prototype: { type: "string" },
736
+ },
737
+ },
738
+ }),
739
+ );
740
+ const schemas = evaluate(files["t/schemas.ts"], vm.createContext({}), {});
741
+ expect(schemas.BodySchema.safeParse({ constructor: 1 }).success).toBe(false);
742
+ expect(schemas.BodySchema.safeParse({ constructor: "x" }).success).toBe(true);
743
+ });
744
+
745
+ it("a nested-quantifier pattern still generates and comes back as a warning naming the schema", () => {
746
+ const { files, warnings } = generate(
747
+ spec(POST_BODY, {
748
+ Body: {
749
+ type: "object",
750
+ properties: {
751
+ slug: { type: "string", pattern: "^([a-z0-9]+-)*[a-z0-9]+$" },
752
+ evil: { type: "string", pattern: "^(a+)+$" },
753
+ },
754
+ },
755
+ }),
756
+ );
757
+ expect(files["t/schemas.ts"]).toContain('new RegExp("^(a+)+$")');
758
+ expect(warnings).toHaveLength(1);
759
+ expect(warnings[0]).toMatch(/schema "Body" at properties\.evil: pattern "\^\(a\+\)\+\$"/);
760
+ });
761
+
762
+ it("terminal control characters in a tag do not reach the generated doc comment", () => {
763
+ const { files, tags } = generate(
764
+ spec({ "/a": { get: { tags: [ESC_TAG], responses: OK } } }),
765
+ );
766
+ const service = Object.entries(files).find(([f]) => f.endsWith("/service.ts"))[1];
767
+ expect(tags).toEqual([ESC_TAG]);
768
+ expect(service).not.toMatch(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/);
769
+ expect(service).toContain('the "items]0;PWNED31m " routes');
770
+ });
771
+
772
+ it("the folder and class reported for each group are the ones written", () => {
773
+ const { files, services } = generate(
774
+ spec({ "/a": { get: { tags: ["Items API"], responses: OK } } }),
775
+ );
776
+ expect(services).toEqual([{ slug: "items-api", className: "ItemsApiService" }]);
777
+ expect(files["items-api/service.ts"]).toContain("export class ItemsApiService");
778
+ });
779
+
780
+ it("the output for a spec with control characters and a flagged pattern typechecks", () => {
781
+ const { files } = generate(
782
+ spec(
783
+ {
784
+ "/a/{id}": {
785
+ post: {
786
+ tags: ["t"],
787
+ summary: "x\u001b[2J\u009d8;;https://evil/\u009c",
788
+ parameters: [
789
+ {
790
+ name: "id",
791
+ in: "path",
792
+ required: true,
793
+ schema: { type: "string" },
794
+ },
795
+ ],
796
+ requestBody: JSON_BODY("Body"),
797
+ responses: OK,
798
+ },
799
+ },
800
+ },
801
+ {
802
+ Body: {
803
+ type: "object",
804
+ properties: { evil: { type: "string", pattern: "(\\w+\\s?)*" } },
805
+ },
806
+ },
807
+ ),
808
+ );
809
+ expect(typecheck(files, "t")).toEqual([]);
810
+ });
811
+ });
@@ -1,6 +1,13 @@
1
1
  // OpenAPI 3.x → per-tag { schemas.ts (Zod), types.ts, service.ts (class) }.
2
2
  // Pure: takes the parsed spec object, returns a { path: contents } map. No I/O.
3
- import { assertNoCollision, commentText, toIdentifier, urlTemplate } from "./escape.mjs";
3
+ import {
4
+ assertNoCollision,
5
+ assertSafePath,
6
+ commentText,
7
+ toIdentifier,
8
+ urlTemplate,
9
+ } from "./escape.mjs";
10
+ import { hasNestedQuantifier } from "./pattern-risk.mjs";
4
11
  import { refName, schemaToZod, typeName, zodName } from "./schema-to-zod.mjs";
5
12
 
6
13
  const HTTP_METHODS = ["get", "post", "put", "patch", "delete"];
@@ -157,30 +164,77 @@ function isSegmentScalar(schema, schemas, depth = 0) {
157
164
  }
158
165
 
159
166
  /**
160
- * Check every `pattern` in a schema compiles as a JavaScript regex.
167
+ * Whether a JSON value has an own key `__proto__` at any depth.
161
168
  *
162
- * The generated Zod schema builds it with `new RegExp(...)` when the module is
163
- * imported, so an invalid one would only surface in the app at runtime — a blank
164
- * screen. Failing here names the schema and where in it the pattern sits.
169
+ * @param {unknown} value - A value parsed from the spec.
170
+ * @returns {boolean}
171
+ */
172
+ function hasProtoKey(value) {
173
+ if (!value || typeof value !== "object") return false;
174
+ if (Object.hasOwn(value, "__proto__")) return true;
175
+ return Object.values(value).some(hasProtoKey);
176
+ }
177
+
178
+ /**
179
+ * Check a component schema before it is turned into Zod source.
180
+ *
181
+ * - Every `pattern` must compile as a JavaScript regex. The generated schema
182
+ * builds it with `new RegExp(...)` when the module is imported, so an invalid
183
+ * one would only surface in the app at runtime — a blank screen.
184
+ * - A `pattern` with the nested-quantifier shape ({@link hasNestedQuantifier})
185
+ * is pushed to `warnings`: it compiles, and it can freeze the tab on one
186
+ * crafted value, but a heuristic does not get to fail the run.
187
+ * - A property, or a key inside a `default`, named `__proto__` is refused. The
188
+ * generated source is an object literal, where `"__proto__": ...` sets the
189
+ * prototype instead of adding a key: the field vanished from the Zod shape and
190
+ * the value went unvalidated, and Zod 4 drops the key even when it is written
191
+ * as a computed `["__proto__"]` (measured on 4.6.7: a required `__proto__:
192
+ * z.string()` accepted `{}` and `5`). There is no source that validates it, so
193
+ * the spec is refused (#533).
194
+ *
195
+ * Every failure names the schema and where in it the problem sits.
165
196
  *
166
197
  * @param {unknown} node - The schema (sub)node.
167
- * @param {string} schema - The component schema name, for the error.
198
+ * @param {string} schema - The component schema name, for the messages.
199
+ * @param {string[]} warnings - Collects the non-fatal findings.
168
200
  * @param {string[]} [at] - Keys from the schema root to `node`.
169
- * @throws {Error} When a pattern does not compile.
201
+ * @throws {Error} When a pattern does not compile, or a `__proto__` key would be
202
+ * emitted.
170
203
  */
171
- function assertPatterns(node, schema, at = []) {
204
+ function checkSchema(node, schema, warnings, at = []) {
172
205
  if (!node || typeof node !== "object") return;
206
+ const where = `OpenAPI schema ${JSON.stringify(schema)} at ${at.join(".") || "(root)"}`;
173
207
  if (typeof node.pattern === "string") {
174
208
  try {
175
209
  new RegExp(node.pattern);
176
210
  } catch (err) {
177
211
  throw new Error(
178
- `OpenAPI schema ${JSON.stringify(schema)} at ${at.join(".") || "(root)"}: pattern ${JSON.stringify(node.pattern)} is not a valid JavaScript regular expression (${err.message}).`,
212
+ `${where}: pattern ${JSON.stringify(node.pattern)} is not a valid JavaScript regular expression (${err.message}).`,
179
213
  );
180
214
  }
215
+ if (hasNestedQuantifier(node.pattern)) {
216
+ warnings.push(
217
+ `${where}: pattern ${JSON.stringify(node.pattern)} repeats a group that ends in a repetition; it can backtrack exponentially on a near-miss value and freeze the tab that validates it.`,
218
+ );
219
+ }
220
+ }
221
+ if (
222
+ node.properties &&
223
+ typeof node.properties === "object" &&
224
+ Object.hasOwn(node.properties, "__proto__")
225
+ ) {
226
+ throw new Error(
227
+ `${where}: a property named "__proto__" cannot be validated — in the generated object literal it sets the prototype instead of declaring the field. Rename it in the spec.`,
228
+ );
229
+ }
230
+ if (hasProtoKey(node.default)) {
231
+ throw new Error(
232
+ `${where}: the default value has a "__proto__" key, which the generated object literal would turn into its prototype. Remove it from the spec.`,
233
+ );
181
234
  }
182
235
  for (const [key, value] of Object.entries(node)) {
183
- if (key !== "pattern") assertPatterns(value, schema, [...at, key]);
236
+ if (key !== "pattern" && key !== "default")
237
+ checkSchema(value, schema, warnings, [...at, key]);
184
238
  }
185
239
  }
186
240
 
@@ -432,8 +486,15 @@ function compareNames(a, b) {
432
486
  * because a browser `fetch` cannot set `Cookie` — the browser sends its own
433
487
  * cookies with `credentials`, which is how a cookie parameter reaches the server.
434
488
  *
489
+ * Every path key that has an operation goes through {@link assertSafePath}, and
490
+ * every emitted component schema through {@link checkSchema}: spec text that
491
+ * would turn into a call to another origin, or into an object literal whose
492
+ * prototype the spec picks, fails the run before anything is written (#533).
493
+ *
435
494
  * @param {object} doc - The parsed OpenAPI 3.x spec.
436
- * @returns {{ files: Record<string, string>, tags: string[] }}
495
+ * @returns {{ files: Record<string, string>, tags: string[], services: { slug: string, className: string }[], warnings: string[] }}
496
+ * The files by relative path; each group's spec tag, folder and service class
497
+ * (in the same order); and the non-fatal findings the CLI prints.
437
498
  */
438
499
  export function generate(doc) {
439
500
  const schemas = doc.components?.schemas ?? {};
@@ -444,6 +505,7 @@ export function generate(doc) {
444
505
  for (const method of HTTP_METHODS) {
445
506
  const op = item[method];
446
507
  if (!op) continue;
508
+ assertSafePath(path);
447
509
  const tag = op.tags?.[0] ?? "default";
448
510
  const slug = tagSlug(tag);
449
511
  if (!groups.has(slug)) groups.set(slug, { tag, slug, ops: [] });
@@ -459,10 +521,13 @@ export function generate(doc) {
459
521
 
460
522
  const files = {};
461
523
  const tags = [];
524
+ const services = [];
525
+ const warnings = [];
462
526
 
463
527
  for (const { tag, slug, ops } of groups.values()) {
464
528
  tags.push(tag);
465
529
  const Class = `${slugIdent(pascal(slug))}Service`;
530
+ services.push({ slug, className: Class });
466
531
 
467
532
  const usedNames = new Set(CLASS_MEMBERS);
468
533
  const signature = { refs: new Set(), validates: false, sendsHeaders: false };
@@ -483,7 +548,7 @@ export function generate(doc) {
483
548
  sorted.map((n) => [n, typeName(n)]),
484
549
  "schema names",
485
550
  );
486
- for (const name of sorted) assertPatterns(schemas[name], name);
551
+ for (const name of sorted) checkSchema(schemas[name], name, warnings);
487
552
 
488
553
  const schemaLines = sorted.map((name) => {
489
554
  const expr = schemaToZod(schemas[name], (ref) => {
@@ -542,7 +607,7 @@ export function generate(doc) {
542
607
  .map((slug) => `export * as ${slugIdent(slug)} from "./${slug}";`)
543
608
  .join("\n") + "\n";
544
609
 
545
- return { files, tags };
610
+ return { files, tags, services, warnings };
546
611
  }
547
612
 
548
613
  /**
@@ -4,11 +4,23 @@ import { fileURLToPath } from "node:url";
4
4
 
5
5
  import { ESLint } from "eslint";
6
6
  import ts from "typescript";
7
- import { describe, expect, it } from "vitest";
7
+ import { describe, expect, it, vi } from "vitest";
8
8
 
9
9
  import { encodeIdSegment } from "../../../src/data";
10
10
  import { generate } from "./generate.mjs";
11
11
 
12
+ /**
13
+ * Gives the tests in this file 60 s instead of Vitest's 5 s default.
14
+ *
15
+ * Several of them build a TypeScript program (and, some, an ESLint instance)
16
+ * in-process over the generated files. That takes 3-5 s on an idle machine,
17
+ * right at the default, so a busy one — another suite, a model training — failed
18
+ * them by timeout while the code was correct: 5.1-5.7 s measured in three
19
+ * `make check` runs on 2026-11-01, load average between 21 and 48.
20
+ * `test/docs-guard.test.ts` gives its compile 120 s for the same reason.
21
+ */
22
+ vi.setConfig({ testTimeout: 60_000 });
23
+
12
24
  const SPEC = {
13
25
  openapi: "3.1.0",
14
26
  components: {
@@ -0,0 +1,134 @@
1
+ /**
2
+ * Static check for the regular expressions a spec's `pattern` keywords become.
3
+ *
4
+ * The generated Zod schema compiles each `pattern` with `new RegExp(...)` and
5
+ * runs it on every value the app validates. V8 backtracks, so a pattern with a
6
+ * repeated group whose body can itself match a variable amount — `^(a+)+$` — is
7
+ * exponential on a near-miss input: 28 characters took 873 ms and every extra
8
+ * one doubles it (Node 24.15, `/^(a+)+$/.test("a".repeat(28) + "!")`). The app
9
+ * tab freezes on its own data. A spec is input the developer does not control,
10
+ * so `tempest gen api` flags such a pattern when it generates the schema (#533).
11
+ */
12
+
13
+ /**
14
+ * Parse the quantifier starting at `pattern[i]`, if any.
15
+ *
16
+ * @param {string} pattern - The regex source.
17
+ * @param {number} i - Index right after an atom.
18
+ * @returns {{ min: number, max: number, length: number } | null} The bounds
19
+ * (`max` is `Infinity` when unbounded) and how many characters the quantifier
20
+ * spans, its lazy `?` included; `null` when no quantifier starts there.
21
+ */
22
+ function quantifierAt(pattern, i) {
23
+ const ch = pattern[i];
24
+ let q = null;
25
+ if (ch === "*") q = { min: 0, max: Infinity, length: 1 };
26
+ else if (ch === "+") q = { min: 1, max: Infinity, length: 1 };
27
+ else if (ch === "?") q = { min: 0, max: 1, length: 1 };
28
+ else if (ch === "{") {
29
+ const m = /^\{(\d+)(,(\d*))?\}/.exec(pattern.slice(i));
30
+ if (m) {
31
+ const min = Number(m[1]);
32
+ const max = m[2] === undefined ? min : m[3] === "" ? Infinity : Number(m[3]);
33
+ q = { min, max, length: m[0].length };
34
+ }
35
+ }
36
+ if (q && pattern[i + q.length] === "?") q.length += 1;
37
+ return q;
38
+ }
39
+
40
+ /**
41
+ * Index right after the character class that opens at `pattern[i]` (`[`).
42
+ *
43
+ * @param {string} pattern - The regex source.
44
+ * @param {number} i - Index of the `[`.
45
+ * @returns {number} Index after the closing `]`, or the pattern's length.
46
+ */
47
+ function classEnd(pattern, i) {
48
+ let j = i + 1;
49
+ while (j < pattern.length && pattern[j] !== "]") j += pattern[j] === "\\" ? 2 : 1;
50
+ return j + 1;
51
+ }
52
+
53
+ /**
54
+ * Length of a group's opening syntax at `pattern[i]` (`(`, `(?:`, `(?=`,
55
+ * `(?<name>`, ...), so its `?` is not read as a quantifier.
56
+ *
57
+ * @param {string} pattern - The regex source.
58
+ * @param {number} i - Index of the `(`.
59
+ * @returns {number} Characters to skip.
60
+ */
61
+ function groupOpenLength(pattern, i) {
62
+ if (pattern[i + 1] !== "?") return 1;
63
+ if (pattern[i + 2] === "<" && pattern[i + 3] !== "=" && pattern[i + 3] !== "!") {
64
+ const close = pattern.indexOf(">", i);
65
+ return close === -1 ? 1 : close - i + 1;
66
+ }
67
+ return pattern[i + 2] === "<" ? 4 : 3;
68
+ }
69
+
70
+ /**
71
+ * Whether a regex source has a repeated group whose body ends in a
72
+ * variable-length repetition — the nested-quantifier shape behind most
73
+ * catastrophic backtracking (`(a+)+`, `(\w+\s?)*`, `((x*))+`).
74
+ *
75
+ * "Ends in" is what keeps the common safe patterns quiet: in
76
+ * `^([a-z0-9]+-)*[a-z0-9]+$` every iteration must end on the mandatory `-`, so
77
+ * the inner `+` cannot trade characters with the next iteration and the group is
78
+ * not flagged; only optional atoms (`?`, `*`, `{0,n}`) may follow the inner
79
+ * repetition for the group to count. Fixed counts (`(\d{3})+`) are not variable
80
+ * and do not count.
81
+ *
82
+ * This is a heuristic for one shape, not a proof of safety: overlapping
83
+ * alternation (`(a|aa)+`) and backreferences are not analysed. A flag is a
84
+ * warning to read the pattern, not a verdict.
85
+ *
86
+ * @param {string} pattern - The regex source from the spec.
87
+ * @returns {boolean} `true` when the nested-quantifier shape is present.
88
+ */
89
+ export function hasNestedQuantifier(pattern) {
90
+ const frames = [{ tail: false, anyAlternative: false }];
91
+ let i = 0;
92
+ while (i < pattern.length) {
93
+ const ch = pattern[i];
94
+ const frame = frames[frames.length - 1];
95
+ if (ch === "|") {
96
+ frame.anyAlternative ||= frame.tail;
97
+ frame.tail = false;
98
+ i += 1;
99
+ continue;
100
+ }
101
+ if (ch === "(") {
102
+ frames.push({ tail: false, anyAlternative: false });
103
+ i += groupOpenLength(pattern, i);
104
+ continue;
105
+ }
106
+ let groupTail = false;
107
+ let isGroup = false;
108
+ if (ch === ")") {
109
+ if (frames.length === 1) {
110
+ i += 1;
111
+ continue;
112
+ }
113
+ const closed = frames.pop();
114
+ groupTail = closed.anyAlternative || closed.tail;
115
+ isGroup = true;
116
+ i += 1;
117
+ } else if (ch === "[") {
118
+ i = classEnd(pattern, i);
119
+ } else if (ch === "\\") {
120
+ i += 2;
121
+ } else {
122
+ i += 1;
123
+ }
124
+ const outer = frames[frames.length - 1];
125
+ const q = quantifierAt(pattern, i);
126
+ if (q) i += q.length;
127
+ const repeats = q !== null && q.max > 1;
128
+ if (isGroup && groupTail && repeats) return true;
129
+ const variable = repeats && q.max > q.min;
130
+ if (variable || (isGroup && groupTail && !repeats)) outer.tail = true;
131
+ else if (!q || q.min > 0) outer.tail = false;
132
+ }
133
+ return false;
134
+ }
@@ -0,0 +1,45 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { hasNestedQuantifier } from "./pattern-risk.mjs";
4
+
5
+ describe("hasNestedQuantifier — flags the repeated-repetition shape (#533)", () => {
6
+ it.each([
7
+ "^(a+)+$",
8
+ "^(a*)*$",
9
+ "(\\w+\\s?)*",
10
+ "^((x+))+$",
11
+ "(?:a+){2,}",
12
+ "(?<word>\\d+)+",
13
+ "^(a|b+)*$",
14
+ "(a{1,5})+",
15
+ ])("%s is flagged", (pattern) => {
16
+ expect(hasNestedQuantifier(pattern)).toBe(true);
17
+ });
18
+
19
+ it.each([
20
+ "^[a-z]+$",
21
+ "^([a-z0-9]+-)*[a-z0-9]+$",
22
+ "^(\\d{3})+$",
23
+ "^(ab)+$",
24
+ "^(a+)$",
25
+ "^(a+)?$",
26
+ "[(a+)+]",
27
+ "\\(a+\\)+",
28
+ "^(?=a+)b$",
29
+ "^\\d{5}-?\\d{3}$",
30
+ "^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$",
31
+ ")a+(",
32
+ ])("%s is not flagged", (pattern) => {
33
+ expect(hasNestedQuantifier(pattern)).toBe(false);
34
+ });
35
+
36
+ it("the flagged shape really is exponential in V8, so the warning is not theoretical", () => {
37
+ const time = (n) => {
38
+ const start = performance.now();
39
+ /^(a+)+$/.test(`${"a".repeat(n)}!`);
40
+ return performance.now() - start;
41
+ };
42
+ time(14);
43
+ expect(time(22)).toBeGreaterThan(time(14) * 4);
44
+ });
45
+ });