tempest-react-sdk 0.81.0 → 0.83.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 (194) hide show
  1. package/README.md +9 -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/doctor/doctor.e2e.test.mjs +29 -0
  5. package/bin/lib/doctor/vitest.mjs +120 -0
  6. package/bin/lib/doctor/vitest.test.mjs +139 -0
  7. package/bin/lib/fs/contain.mjs +7 -0
  8. package/bin/lib/openapi/escape.mjs +68 -4
  9. package/bin/lib/openapi/generate.hostile.test.mjs +228 -0
  10. package/bin/lib/openapi/generate.mjs +119 -15
  11. package/bin/lib/openapi/pattern-risk.mjs +134 -0
  12. package/bin/lib/openapi/pattern-risk.test.mjs +45 -0
  13. package/bin/tempest-gen.test.mjs +100 -0
  14. package/bin/tempest.mjs +24 -8
  15. package/dist/access/use-can.cjs +1 -1
  16. package/dist/access/use-can.cjs.map +1 -1
  17. package/dist/access/use-can.js +25 -28
  18. package/dist/access/use-can.js.map +1 -1
  19. package/dist/auth/create-tempest-auth.cjs +1 -1
  20. package/dist/auth/create-tempest-auth.cjs.map +1 -1
  21. package/dist/auth/create-tempest-auth.js +189 -143
  22. package/dist/auth/create-tempest-auth.js.map +1 -1
  23. package/dist/auth/session-identity.cjs +2 -0
  24. package/dist/auth/session-identity.cjs.map +1 -0
  25. package/dist/auth/session-identity.js +12 -0
  26. package/dist/auth/session-identity.js.map +1 -0
  27. package/dist/br/boleto.cjs.map +1 -1
  28. package/dist/br/boleto.js.map +1 -1
  29. package/dist/components/AuthForm/AuthLinks.cjs +1 -1
  30. package/dist/components/AuthForm/AuthLinks.cjs.map +1 -1
  31. package/dist/components/AuthForm/AuthLinks.js +18 -15
  32. package/dist/components/AuthForm/AuthLinks.js.map +1 -1
  33. package/dist/components/AuthPage/AuthPage.cjs +1 -1
  34. package/dist/components/AuthPage/AuthPage.cjs.map +1 -1
  35. package/dist/components/AuthPage/AuthPage.js +58 -52
  36. package/dist/components/AuthPage/AuthPage.js.map +1 -1
  37. package/dist/components/Breadcrumbs/Breadcrumbs.cjs +1 -1
  38. package/dist/components/Breadcrumbs/Breadcrumbs.cjs.map +1 -1
  39. package/dist/components/Breadcrumbs/Breadcrumbs.js +22 -21
  40. package/dist/components/Breadcrumbs/Breadcrumbs.js.map +1 -1
  41. package/dist/components/Button/Button.cjs +1 -1
  42. package/dist/components/Button/Button.cjs.map +1 -1
  43. package/dist/components/Button/Button.js +41 -40
  44. package/dist/components/Button/Button.js.map +1 -1
  45. package/dist/components/Chat/ChatAttachments.cjs +1 -1
  46. package/dist/components/Chat/ChatAttachments.cjs.map +1 -1
  47. package/dist/components/Chat/ChatAttachments.js +38 -38
  48. package/dist/components/Form/Form.cjs +1 -1
  49. package/dist/components/Form/Form.cjs.map +1 -1
  50. package/dist/components/Form/Form.js +30 -32
  51. package/dist/components/Form/Form.js.map +1 -1
  52. package/dist/components/Layout/Layout.cjs +1 -1
  53. package/dist/components/Layout/Layout.cjs.map +1 -1
  54. package/dist/components/Layout/Layout.js +25 -24
  55. package/dist/components/Layout/Layout.js.map +1 -1
  56. package/dist/components/Layout/gap.cjs +2 -0
  57. package/dist/components/Layout/gap.cjs.map +1 -0
  58. package/dist/components/Layout/gap.js +15 -0
  59. package/dist/components/Layout/gap.js.map +1 -0
  60. package/dist/components/NavigationMenu/NavigationMenu.cjs +1 -1
  61. package/dist/components/NavigationMenu/NavigationMenu.cjs.map +1 -1
  62. package/dist/components/NavigationMenu/NavigationMenu.js +71 -70
  63. package/dist/components/NavigationMenu/NavigationMenu.js.map +1 -1
  64. package/dist/components/Sidebar/Sidebar.cjs.map +1 -1
  65. package/dist/components/Sidebar/Sidebar.js.map +1 -1
  66. package/dist/components/Sidebar/SidebarEntry.cjs +1 -1
  67. package/dist/components/Sidebar/SidebarEntry.cjs.map +1 -1
  68. package/dist/components/Sidebar/SidebarEntry.js +30 -29
  69. package/dist/components/Sidebar/SidebarEntry.js.map +1 -1
  70. package/dist/data/create-data-provider.cjs +1 -1
  71. package/dist/data/create-data-provider.cjs.map +1 -1
  72. package/dist/data/create-data-provider.js +1 -2
  73. package/dist/data/create-data-provider.js.map +1 -1
  74. package/dist/forms/validate-form.cjs +1 -1
  75. package/dist/forms/validate-form.cjs.map +1 -1
  76. package/dist/forms/validate-form.js +6 -1
  77. package/dist/forms/validate-form.js.map +1 -1
  78. package/dist/forms/zod-resolver.cjs +1 -1
  79. package/dist/forms/zod-resolver.cjs.map +1 -1
  80. package/dist/forms/zod-resolver.js +8 -3
  81. package/dist/forms/zod-resolver.js.map +1 -1
  82. package/dist/http/api-client.cjs +1 -1
  83. package/dist/http/api-client.cjs.map +1 -1
  84. package/dist/http/api-client.js +90 -72
  85. package/dist/http/api-client.js.map +1 -1
  86. package/dist/http/credential-scope.cjs +1 -1
  87. package/dist/http/credential-scope.cjs.map +1 -1
  88. package/dist/http/credential-scope.js +33 -9
  89. package/dist/http/credential-scope.js.map +1 -1
  90. package/dist/http/presigned-post.cjs.map +1 -1
  91. package/dist/http/presigned-post.js.map +1 -1
  92. package/dist/http/resumable-upload.cjs +1 -1
  93. package/dist/http/resumable-upload.cjs.map +1 -1
  94. package/dist/http/resumable-upload.js +167 -112
  95. package/dist/http/resumable-upload.js.map +1 -1
  96. package/dist/http/upload-with-progress.cjs +1 -1
  97. package/dist/http/upload-with-progress.cjs.map +1 -1
  98. package/dist/http/upload-with-progress.js +100 -44
  99. package/dist/http/upload-with-progress.js.map +1 -1
  100. package/dist/oauth/GoogleSignIn.cjs +1 -1
  101. package/dist/oauth/GoogleSignIn.cjs.map +1 -1
  102. package/dist/oauth/GoogleSignIn.js +5 -4
  103. package/dist/oauth/GoogleSignIn.js.map +1 -1
  104. package/dist/offline/create-offline-sync.cjs +1 -1
  105. package/dist/offline/create-offline-sync.cjs.map +1 -1
  106. package/dist/offline/create-offline-sync.js +87 -69
  107. package/dist/offline/create-offline-sync.js.map +1 -1
  108. package/dist/query/offline-persistence.cjs +1 -1
  109. package/dist/query/offline-persistence.cjs.map +1 -1
  110. package/dist/query/offline-persistence.js +67 -44
  111. package/dist/query/offline-persistence.js.map +1 -1
  112. package/dist/sse/create-event-stream.cjs +1 -1
  113. package/dist/sse/create-event-stream.cjs.map +1 -1
  114. package/dist/sse/create-event-stream.js +48 -41
  115. package/dist/sse/create-event-stream.js.map +1 -1
  116. package/dist/sse/ticket-event-stream.cjs +1 -1
  117. package/dist/sse/ticket-event-stream.cjs.map +1 -1
  118. package/dist/sse/ticket-event-stream.js +48 -19
  119. package/dist/sse/ticket-event-stream.js.map +1 -1
  120. package/dist/sse/use-event-stream.cjs +1 -1
  121. package/dist/sse/use-event-stream.cjs.map +1 -1
  122. package/dist/sse/use-event-stream.js +19 -14
  123. package/dist/sse/use-event-stream.js.map +1 -1
  124. package/dist/sw/background-sync.cjs +1 -1
  125. package/dist/sw/background-sync.cjs.map +1 -1
  126. package/dist/sw/background-sync.js +10 -8
  127. package/dist/sw/background-sync.js.map +1 -1
  128. package/dist/sw/cache.cjs +1 -1
  129. package/dist/sw/cache.cjs.map +1 -1
  130. package/dist/sw/cache.js +111 -55
  131. package/dist/sw/cache.js.map +1 -1
  132. package/dist/sw/create-push-handler.cjs +1 -1
  133. package/dist/sw/create-push-handler.cjs.map +1 -1
  134. package/dist/sw/create-push-handler.js +20 -10
  135. package/dist/sw/create-push-handler.js.map +1 -1
  136. package/dist/sw.d.ts +65 -14
  137. package/dist/tabular/compact.cjs +1 -1
  138. package/dist/tabular/compact.cjs.map +1 -1
  139. package/dist/tabular/compact.js +35 -16
  140. package/dist/tabular/compact.js.map +1 -1
  141. package/dist/tabular/manifest.cjs +1 -1
  142. package/dist/tabular/manifest.cjs.map +1 -1
  143. package/dist/tabular/manifest.js +33 -15
  144. package/dist/tabular/manifest.js.map +1 -1
  145. package/dist/tabular.d.ts +11 -1
  146. package/dist/tempest-react-sdk.cjs +1 -1
  147. package/dist/tempest-react-sdk.d.ts +543 -70
  148. package/dist/tempest-react-sdk.js +224 -224
  149. package/dist/theme/initial-theme.cjs +4 -4
  150. package/dist/theme/initial-theme.cjs.map +1 -1
  151. package/dist/theme/initial-theme.js +8 -5
  152. package/dist/theme/initial-theme.js.map +1 -1
  153. package/dist/utils/compressed-storage.cjs +1 -1
  154. package/dist/utils/compressed-storage.cjs.map +1 -1
  155. package/dist/utils/compressed-storage.js +13 -7
  156. package/dist/utils/compressed-storage.js.map +1 -1
  157. package/dist/utils/objects.cjs +1 -1
  158. package/dist/utils/objects.cjs.map +1 -1
  159. package/dist/utils/objects.js +13 -7
  160. package/dist/utils/objects.js.map +1 -1
  161. package/dist/utils/safe-url.cjs +1 -1
  162. package/dist/utils/safe-url.cjs.map +1 -1
  163. package/dist/utils/safe-url.js +4 -1
  164. package/dist/utils/safe-url.js.map +1 -1
  165. package/dist/vite/base-url.cjs +1 -1
  166. package/dist/vite/base-url.cjs.map +1 -1
  167. package/dist/vite/base-url.js +5 -1
  168. package/dist/vite/base-url.js.map +1 -1
  169. package/dist/vite/tempest-pwa-dev-sw.cjs +1 -1
  170. package/dist/vite/tempest-pwa-dev-sw.cjs.map +1 -1
  171. package/dist/vite/tempest-pwa-dev-sw.js +96 -31
  172. package/dist/vite/tempest-pwa-dev-sw.js.map +1 -1
  173. package/dist/vite/tempest-pwa-manifest.cjs +2 -2
  174. package/dist/vite/tempest-pwa-manifest.cjs.map +1 -1
  175. package/dist/vite/tempest-pwa-manifest.js +32 -16
  176. package/dist/vite/tempest-pwa-manifest.js.map +1 -1
  177. package/dist/vite.cjs +1 -1
  178. package/dist/vite.d.ts +63 -12
  179. package/dist/vite.js +4 -4
  180. package/dist/webrtc/peer-mesh.cjs +1 -1
  181. package/dist/webrtc/peer-mesh.cjs.map +1 -1
  182. package/dist/webrtc/peer-mesh.js +7 -1
  183. package/dist/webrtc/peer-mesh.js.map +1 -1
  184. package/dist/ws/create-web-socket.cjs +1 -1
  185. package/dist/ws/create-web-socket.cjs.map +1 -1
  186. package/dist/ws/create-web-socket.js +77 -61
  187. package/dist/ws/create-web-socket.js.map +1 -1
  188. package/dist/ws/use-web-socket.cjs +1 -1
  189. package/dist/ws/use-web-socket.cjs.map +1 -1
  190. package/dist/ws/use-web-socket.js +4 -3
  191. package/dist/ws/use-web-socket.js.map +1 -1
  192. package/package.json +1 -1
  193. package/template-pwa/src/pwa.tsx +42 -0
  194. package/template-pwa/src/sw.ts +5 -4
@@ -672,3 +672,231 @@ describe("generate — names that collide with what the generator itself declare
672
672
  );
673
673
  });
674
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
+ const KEYWORD_NAMES = [
728
+ "pattern",
729
+ "default",
730
+ "enum",
731
+ "items",
732
+ "properties",
733
+ "additionalProperties",
734
+ "allOf",
735
+ "anyOf",
736
+ "oneOf",
737
+ "required",
738
+ "type",
739
+ "$ref",
740
+ ];
741
+
742
+ /**
743
+ * A `Body` schema with one property, named `name`, whose schema is `inner`.
744
+ *
745
+ * @param {string} name - The property name, often a JSON Schema keyword.
746
+ * @param {object} inner - The property's schema.
747
+ * @returns {object} The spec.
748
+ */
749
+ function keywordNamedProperty(name, inner) {
750
+ return spec(POST_BODY, {
751
+ Body: { type: "object", required: [name], properties: { [name]: inner } },
752
+ });
753
+ }
754
+
755
+ it.each(KEYWORD_NAMES)(
756
+ "a property named %s holding a required __proto__ is refused, naming where it sits (#562)",
757
+ (name) => {
758
+ const inner = JSON.parse(
759
+ '{"type":"object","required":["__proto__"],"properties":{"__proto__":{"type":"string"}}}',
760
+ );
761
+ expect(() => generate(keywordNamedProperty(name, inner))).toThrow(
762
+ `schema "Body" at properties.${name}: a property named "__proto__" cannot be validated`,
763
+ );
764
+ },
765
+ );
766
+
767
+ it.each(KEYWORD_NAMES)(
768
+ "a property named %s whose schema has a __proto__ default is refused at that property (#562)",
769
+ (name) => {
770
+ const inner = JSON.parse('{"type":"object","default":{"__proto__":{"admin":true}}}');
771
+ expect(() => generate(keywordNamedProperty(name, inner))).toThrow(
772
+ `schema "Body" at properties.${name}: the default value has a "__proto__" key`,
773
+ );
774
+ },
775
+ );
776
+
777
+ it.each(KEYWORD_NAMES)(
778
+ "an invalid regex under a property named %s fails generation (#562)",
779
+ (name) => {
780
+ const inner = {
781
+ type: "object",
782
+ properties: { code: { type: "string", pattern: "(" } },
783
+ };
784
+ expect(() => generate(keywordNamedProperty(name, inner))).toThrow(
785
+ `schema "Body" at properties.${name}.properties.code: pattern "(" is not a valid`,
786
+ );
787
+ },
788
+ );
789
+
790
+ it.each(KEYWORD_NAMES)(
791
+ "a nested-quantifier pattern under a property named %s comes back as a warning (#562)",
792
+ (name) => {
793
+ const inner = { type: "string", pattern: "^(a+)+$" };
794
+ const { warnings } = generate(keywordNamedProperty(name, inner));
795
+ expect(warnings).toHaveLength(1);
796
+ expect(warnings[0]).toContain(`schema "Body" at properties.${name}: pattern`);
797
+ },
798
+ );
799
+
800
+ it("properties named after keywords generate, typecheck and are each validated (#562)", () => {
801
+ const properties = Object.fromEntries(
802
+ KEYWORD_NAMES.map((name) => [name, { type: "string", minLength: 2 }]),
803
+ );
804
+ const generated = spec(POST_BODY, {
805
+ Body: { type: "object", required: KEYWORD_NAMES, properties },
806
+ });
807
+ const { files, warnings } = generate(generated);
808
+ expect(warnings).toEqual([]);
809
+ expect(typecheck(files, "t")).toEqual([]);
810
+ const schemas = evaluate(files["t/schemas.ts"], vm.createContext({}), {});
811
+ const valid = Object.fromEntries(KEYWORD_NAMES.map((name) => [name, "ok"]));
812
+ expect(schemas.BodySchema.safeParse(valid).success).toBe(true);
813
+ for (const name of KEYWORD_NAMES) {
814
+ expect(schemas.BodySchema.safeParse({ ...valid, [name]: {} }).success).toBe(false);
815
+ }
816
+ });
817
+
818
+ it("properties called constructor or prototype still generate and are validated", () => {
819
+ const { files } = generate(
820
+ spec(POST_BODY, {
821
+ Body: {
822
+ type: "object",
823
+ required: ["constructor"],
824
+ properties: {
825
+ constructor: { type: "string" },
826
+ prototype: { type: "string" },
827
+ },
828
+ },
829
+ }),
830
+ );
831
+ const schemas = evaluate(files["t/schemas.ts"], vm.createContext({}), {});
832
+ expect(schemas.BodySchema.safeParse({ constructor: 1 }).success).toBe(false);
833
+ expect(schemas.BodySchema.safeParse({ constructor: "x" }).success).toBe(true);
834
+ });
835
+
836
+ it("a nested-quantifier pattern still generates and comes back as a warning naming the schema", () => {
837
+ const { files, warnings } = generate(
838
+ spec(POST_BODY, {
839
+ Body: {
840
+ type: "object",
841
+ properties: {
842
+ slug: { type: "string", pattern: "^([a-z0-9]+-)*[a-z0-9]+$" },
843
+ evil: { type: "string", pattern: "^(a+)+$" },
844
+ },
845
+ },
846
+ }),
847
+ );
848
+ expect(files["t/schemas.ts"]).toContain('new RegExp("^(a+)+$")');
849
+ expect(warnings).toHaveLength(1);
850
+ expect(warnings[0]).toMatch(/schema "Body" at properties\.evil: pattern "\^\(a\+\)\+\$"/);
851
+ });
852
+
853
+ it("terminal control characters in a tag do not reach the generated doc comment", () => {
854
+ const { files, tags } = generate(
855
+ spec({ "/a": { get: { tags: [ESC_TAG], responses: OK } } }),
856
+ );
857
+ const service = Object.entries(files).find(([f]) => f.endsWith("/service.ts"))[1];
858
+ expect(tags).toEqual([ESC_TAG]);
859
+ expect(service).not.toMatch(/[\u0000-\u0008\u000b-\u001f\u007f-\u009f]/);
860
+ expect(service).toContain('the "items]0;PWNED31m " routes');
861
+ });
862
+
863
+ it("the folder and class reported for each group are the ones written", () => {
864
+ const { files, services } = generate(
865
+ spec({ "/a": { get: { tags: ["Items API"], responses: OK } } }),
866
+ );
867
+ expect(services).toEqual([{ slug: "items-api", className: "ItemsApiService" }]);
868
+ expect(files["items-api/service.ts"]).toContain("export class ItemsApiService");
869
+ });
870
+
871
+ it("the output for a spec with control characters and a flagged pattern typechecks", () => {
872
+ const { files } = generate(
873
+ spec(
874
+ {
875
+ "/a/{id}": {
876
+ post: {
877
+ tags: ["t"],
878
+ summary: "x\u001b[2J\u009d8;;https://evil/\u009c",
879
+ parameters: [
880
+ {
881
+ name: "id",
882
+ in: "path",
883
+ required: true,
884
+ schema: { type: "string" },
885
+ },
886
+ ],
887
+ requestBody: JSON_BODY("Body"),
888
+ responses: OK,
889
+ },
890
+ },
891
+ },
892
+ {
893
+ Body: {
894
+ type: "object",
895
+ properties: { evil: { type: "string", pattern: "(\\w+\\s?)*" } },
896
+ },
897
+ },
898
+ ),
899
+ );
900
+ expect(typecheck(files, "t")).toEqual([]);
901
+ });
902
+ });
@@ -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,37 +164,123 @@ 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
+ * Keywords whose value maps a name the spec author picked to a sub-schema.
180
+ *
181
+ * Their keys are data, not keywords: a property may be called `pattern`,
182
+ * `default` or `$ref`. {@link checkSchema} walks into every entry as a schema
183
+ * and never reads the map itself as one — it used to, so a property named
184
+ * `pattern` or `default` was skipped as if it were the keyword, and its schema
185
+ * escaped every check below (#562).
186
+ */
187
+ const SCHEMA_MAPS = new Set([
188
+ "properties",
189
+ "patternProperties",
190
+ "dependentSchemas",
191
+ "$defs",
192
+ "definitions",
193
+ ]);
194
+
195
+ /**
196
+ * Check a component schema before it is turned into Zod source.
197
+ *
198
+ * - Every `pattern` must compile as a JavaScript regex. The generated schema
199
+ * builds it with `new RegExp(...)` when the module is imported, so an invalid
200
+ * one would only surface in the app at runtime — a blank screen.
201
+ * - A `pattern` with the nested-quantifier shape ({@link hasNestedQuantifier})
202
+ * is pushed to `warnings`: it compiles, and it can freeze the tab on one
203
+ * crafted value, but a heuristic does not get to fail the run.
204
+ * - A property, or a key inside a `default`, named `__proto__` is refused. The
205
+ * generated source is an object literal, where `"__proto__": ...` sets the
206
+ * prototype instead of adding a key: the field vanished from the Zod shape and
207
+ * the value went unvalidated, and Zod 4 drops the key even when it is written
208
+ * as a computed `["__proto__"]` (measured on 4.6.7: a required `__proto__:
209
+ * z.string()` accepted `{}` and `5`). There is no source that validates it, so
210
+ * the spec is refused (#533).
211
+ *
212
+ * Every failure names the schema and where in it the problem sits.
213
+ *
214
+ * The walk reaches every sub-schema. Only a schema's own `pattern` string and
215
+ * `default` value are not walked as schemas — the checks above read them — and
216
+ * the entries of a {@link SCHEMA_MAPS} keyword are always walked, whatever the
217
+ * property is called (#562).
165
218
  *
166
219
  * @param {unknown} node - The schema (sub)node.
167
- * @param {string} schema - The component schema name, for the error.
220
+ * @param {string} schema - The component schema name, for the messages.
221
+ * @param {string[]} warnings - Collects the non-fatal findings.
168
222
  * @param {string[]} [at] - Keys from the schema root to `node`.
169
- * @throws {Error} When a pattern does not compile.
223
+ * @throws {Error} When a pattern does not compile, or a `__proto__` key would be
224
+ * emitted.
170
225
  */
171
- function assertPatterns(node, schema, at = []) {
226
+ function checkSchema(node, schema, warnings, at = []) {
172
227
  if (!node || typeof node !== "object") return;
228
+ const where = `OpenAPI schema ${JSON.stringify(schema)} at ${at.join(".") || "(root)"}`;
173
229
  if (typeof node.pattern === "string") {
174
230
  try {
175
231
  new RegExp(node.pattern);
176
232
  } catch (err) {
177
233
  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}).`,
234
+ `${where}: pattern ${JSON.stringify(node.pattern)} is not a valid JavaScript regular expression (${err.message}).`,
235
+ );
236
+ }
237
+ if (hasNestedQuantifier(node.pattern)) {
238
+ warnings.push(
239
+ `${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.`,
179
240
  );
180
241
  }
181
242
  }
243
+ if (
244
+ node.properties &&
245
+ typeof node.properties === "object" &&
246
+ Object.hasOwn(node.properties, "__proto__")
247
+ ) {
248
+ throw new Error(
249
+ `${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.`,
250
+ );
251
+ }
252
+ if (hasProtoKey(node.default)) {
253
+ throw new Error(
254
+ `${where}: the default value has a "__proto__" key, which the generated object literal would turn into its prototype. Remove it from the spec.`,
255
+ );
256
+ }
182
257
  for (const [key, value] of Object.entries(node)) {
183
- if (key !== "pattern") assertPatterns(value, schema, [...at, key]);
258
+ if (SCHEMA_MAPS.has(key) && value && typeof value === "object" && !Array.isArray(value)) {
259
+ for (const [name, sub] of Object.entries(value)) {
260
+ checkSchema(sub, schema, warnings, [...at, key, name]);
261
+ }
262
+ } else if (key !== "pattern" && key !== "default") {
263
+ checkSchema(value, schema, warnings, [...at, key]);
264
+ }
184
265
  }
185
266
  }
186
267
 
187
- /** Collect component-schema names referenced (transitively) by a schema node. */
268
+ /**
269
+ * Collect component-schema names referenced (transitively) by a schema node.
270
+ *
271
+ * Only a string `$ref` is a reference: inside `properties`, `$ref` can be a
272
+ * property name whose value is a schema, and reading it as a reference crashed
273
+ * the run with `ref.lastIndexOf is not a function` (#562).
274
+ *
275
+ * @param {unknown} node - The schema (sub)node.
276
+ * @param {Record<string, object>} schemas - `components.schemas`.
277
+ * @param {Set<string>} [acc] - Collects the referenced names.
278
+ * @param {Set<string>} [seen] - Names already expanded, against cycles.
279
+ * @returns {Set<string>} `acc`.
280
+ */
188
281
  function collectRefs(node, schemas, acc = new Set(), seen = new Set()) {
189
282
  if (!node || typeof node !== "object") return acc;
190
- if (node.$ref) {
283
+ if (typeof node.$ref === "string") {
191
284
  const name = refName(node.$ref);
192
285
  if (!acc.has(name)) {
193
286
  acc.add(name);
@@ -432,8 +525,15 @@ function compareNames(a, b) {
432
525
  * because a browser `fetch` cannot set `Cookie` — the browser sends its own
433
526
  * cookies with `credentials`, which is how a cookie parameter reaches the server.
434
527
  *
528
+ * Every path key that has an operation goes through {@link assertSafePath}, and
529
+ * every emitted component schema through {@link checkSchema}: spec text that
530
+ * would turn into a call to another origin, or into an object literal whose
531
+ * prototype the spec picks, fails the run before anything is written (#533).
532
+ *
435
533
  * @param {object} doc - The parsed OpenAPI 3.x spec.
436
- * @returns {{ files: Record<string, string>, tags: string[] }}
534
+ * @returns {{ files: Record<string, string>, tags: string[], services: { slug: string, className: string }[], warnings: string[] }}
535
+ * The files by relative path; each group's spec tag, folder and service class
536
+ * (in the same order); and the non-fatal findings the CLI prints.
437
537
  */
438
538
  export function generate(doc) {
439
539
  const schemas = doc.components?.schemas ?? {};
@@ -444,6 +544,7 @@ export function generate(doc) {
444
544
  for (const method of HTTP_METHODS) {
445
545
  const op = item[method];
446
546
  if (!op) continue;
547
+ assertSafePath(path);
447
548
  const tag = op.tags?.[0] ?? "default";
448
549
  const slug = tagSlug(tag);
449
550
  if (!groups.has(slug)) groups.set(slug, { tag, slug, ops: [] });
@@ -459,10 +560,13 @@ export function generate(doc) {
459
560
 
460
561
  const files = {};
461
562
  const tags = [];
563
+ const services = [];
564
+ const warnings = [];
462
565
 
463
566
  for (const { tag, slug, ops } of groups.values()) {
464
567
  tags.push(tag);
465
568
  const Class = `${slugIdent(pascal(slug))}Service`;
569
+ services.push({ slug, className: Class });
466
570
 
467
571
  const usedNames = new Set(CLASS_MEMBERS);
468
572
  const signature = { refs: new Set(), validates: false, sendsHeaders: false };
@@ -483,7 +587,7 @@ export function generate(doc) {
483
587
  sorted.map((n) => [n, typeName(n)]),
484
588
  "schema names",
485
589
  );
486
- for (const name of sorted) assertPatterns(schemas[name], name);
590
+ for (const name of sorted) checkSchema(schemas[name], name, warnings);
487
591
 
488
592
  const schemaLines = sorted.map((name) => {
489
593
  const expr = schemaToZod(schemas[name], (ref) => {
@@ -542,7 +646,7 @@ export function generate(doc) {
542
646
  .map((slug) => `export * as ${slugIdent(slug)} from "./${slug}";`)
543
647
  .join("\n") + "\n";
544
648
 
545
- return { files, tags };
649
+ return { files, tags, services, warnings };
546
650
  }
547
651
 
548
652
  /**
@@ -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
+ });