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.
- package/README.md +9 -10
- package/bin/create-tempest-app.e2e.test.mjs +31 -0
- package/bin/create-tempest-app.mjs +11 -1
- package/bin/lib/doctor/doctor.e2e.test.mjs +29 -0
- package/bin/lib/doctor/vitest.mjs +120 -0
- package/bin/lib/doctor/vitest.test.mjs +139 -0
- package/bin/lib/fs/contain.mjs +7 -0
- package/bin/lib/openapi/escape.mjs +68 -4
- package/bin/lib/openapi/generate.hostile.test.mjs +228 -0
- package/bin/lib/openapi/generate.mjs +119 -15
- package/bin/lib/openapi/pattern-risk.mjs +134 -0
- package/bin/lib/openapi/pattern-risk.test.mjs +45 -0
- package/bin/tempest-gen.test.mjs +100 -0
- package/bin/tempest.mjs +24 -8
- package/dist/access/use-can.cjs +1 -1
- package/dist/access/use-can.cjs.map +1 -1
- package/dist/access/use-can.js +25 -28
- package/dist/access/use-can.js.map +1 -1
- package/dist/auth/create-tempest-auth.cjs +1 -1
- package/dist/auth/create-tempest-auth.cjs.map +1 -1
- package/dist/auth/create-tempest-auth.js +189 -143
- package/dist/auth/create-tempest-auth.js.map +1 -1
- package/dist/auth/session-identity.cjs +2 -0
- package/dist/auth/session-identity.cjs.map +1 -0
- package/dist/auth/session-identity.js +12 -0
- package/dist/auth/session-identity.js.map +1 -0
- package/dist/br/boleto.cjs.map +1 -1
- package/dist/br/boleto.js.map +1 -1
- package/dist/components/AuthForm/AuthLinks.cjs +1 -1
- package/dist/components/AuthForm/AuthLinks.cjs.map +1 -1
- package/dist/components/AuthForm/AuthLinks.js +18 -15
- package/dist/components/AuthForm/AuthLinks.js.map +1 -1
- package/dist/components/AuthPage/AuthPage.cjs +1 -1
- package/dist/components/AuthPage/AuthPage.cjs.map +1 -1
- package/dist/components/AuthPage/AuthPage.js +58 -52
- package/dist/components/AuthPage/AuthPage.js.map +1 -1
- package/dist/components/Breadcrumbs/Breadcrumbs.cjs +1 -1
- package/dist/components/Breadcrumbs/Breadcrumbs.cjs.map +1 -1
- package/dist/components/Breadcrumbs/Breadcrumbs.js +22 -21
- package/dist/components/Breadcrumbs/Breadcrumbs.js.map +1 -1
- package/dist/components/Button/Button.cjs +1 -1
- package/dist/components/Button/Button.cjs.map +1 -1
- package/dist/components/Button/Button.js +41 -40
- package/dist/components/Button/Button.js.map +1 -1
- package/dist/components/Chat/ChatAttachments.cjs +1 -1
- package/dist/components/Chat/ChatAttachments.cjs.map +1 -1
- package/dist/components/Chat/ChatAttachments.js +38 -38
- package/dist/components/Form/Form.cjs +1 -1
- package/dist/components/Form/Form.cjs.map +1 -1
- package/dist/components/Form/Form.js +30 -32
- package/dist/components/Form/Form.js.map +1 -1
- package/dist/components/Layout/Layout.cjs +1 -1
- package/dist/components/Layout/Layout.cjs.map +1 -1
- package/dist/components/Layout/Layout.js +25 -24
- package/dist/components/Layout/Layout.js.map +1 -1
- package/dist/components/Layout/gap.cjs +2 -0
- package/dist/components/Layout/gap.cjs.map +1 -0
- package/dist/components/Layout/gap.js +15 -0
- package/dist/components/Layout/gap.js.map +1 -0
- package/dist/components/NavigationMenu/NavigationMenu.cjs +1 -1
- package/dist/components/NavigationMenu/NavigationMenu.cjs.map +1 -1
- package/dist/components/NavigationMenu/NavigationMenu.js +71 -70
- package/dist/components/NavigationMenu/NavigationMenu.js.map +1 -1
- package/dist/components/Sidebar/Sidebar.cjs.map +1 -1
- package/dist/components/Sidebar/Sidebar.js.map +1 -1
- package/dist/components/Sidebar/SidebarEntry.cjs +1 -1
- package/dist/components/Sidebar/SidebarEntry.cjs.map +1 -1
- package/dist/components/Sidebar/SidebarEntry.js +30 -29
- package/dist/components/Sidebar/SidebarEntry.js.map +1 -1
- package/dist/data/create-data-provider.cjs +1 -1
- package/dist/data/create-data-provider.cjs.map +1 -1
- package/dist/data/create-data-provider.js +1 -2
- package/dist/data/create-data-provider.js.map +1 -1
- package/dist/forms/validate-form.cjs +1 -1
- package/dist/forms/validate-form.cjs.map +1 -1
- package/dist/forms/validate-form.js +6 -1
- package/dist/forms/validate-form.js.map +1 -1
- package/dist/forms/zod-resolver.cjs +1 -1
- package/dist/forms/zod-resolver.cjs.map +1 -1
- package/dist/forms/zod-resolver.js +8 -3
- package/dist/forms/zod-resolver.js.map +1 -1
- package/dist/http/api-client.cjs +1 -1
- package/dist/http/api-client.cjs.map +1 -1
- package/dist/http/api-client.js +90 -72
- package/dist/http/api-client.js.map +1 -1
- package/dist/http/credential-scope.cjs +1 -1
- package/dist/http/credential-scope.cjs.map +1 -1
- package/dist/http/credential-scope.js +33 -9
- package/dist/http/credential-scope.js.map +1 -1
- package/dist/http/presigned-post.cjs.map +1 -1
- package/dist/http/presigned-post.js.map +1 -1
- package/dist/http/resumable-upload.cjs +1 -1
- package/dist/http/resumable-upload.cjs.map +1 -1
- package/dist/http/resumable-upload.js +167 -112
- package/dist/http/resumable-upload.js.map +1 -1
- package/dist/http/upload-with-progress.cjs +1 -1
- package/dist/http/upload-with-progress.cjs.map +1 -1
- package/dist/http/upload-with-progress.js +100 -44
- package/dist/http/upload-with-progress.js.map +1 -1
- package/dist/oauth/GoogleSignIn.cjs +1 -1
- package/dist/oauth/GoogleSignIn.cjs.map +1 -1
- package/dist/oauth/GoogleSignIn.js +5 -4
- package/dist/oauth/GoogleSignIn.js.map +1 -1
- package/dist/offline/create-offline-sync.cjs +1 -1
- package/dist/offline/create-offline-sync.cjs.map +1 -1
- package/dist/offline/create-offline-sync.js +87 -69
- package/dist/offline/create-offline-sync.js.map +1 -1
- package/dist/query/offline-persistence.cjs +1 -1
- package/dist/query/offline-persistence.cjs.map +1 -1
- package/dist/query/offline-persistence.js +67 -44
- package/dist/query/offline-persistence.js.map +1 -1
- package/dist/sse/create-event-stream.cjs +1 -1
- package/dist/sse/create-event-stream.cjs.map +1 -1
- package/dist/sse/create-event-stream.js +48 -41
- package/dist/sse/create-event-stream.js.map +1 -1
- package/dist/sse/ticket-event-stream.cjs +1 -1
- package/dist/sse/ticket-event-stream.cjs.map +1 -1
- package/dist/sse/ticket-event-stream.js +48 -19
- package/dist/sse/ticket-event-stream.js.map +1 -1
- package/dist/sse/use-event-stream.cjs +1 -1
- package/dist/sse/use-event-stream.cjs.map +1 -1
- package/dist/sse/use-event-stream.js +19 -14
- package/dist/sse/use-event-stream.js.map +1 -1
- package/dist/sw/background-sync.cjs +1 -1
- package/dist/sw/background-sync.cjs.map +1 -1
- package/dist/sw/background-sync.js +10 -8
- package/dist/sw/background-sync.js.map +1 -1
- package/dist/sw/cache.cjs +1 -1
- package/dist/sw/cache.cjs.map +1 -1
- package/dist/sw/cache.js +111 -55
- package/dist/sw/cache.js.map +1 -1
- package/dist/sw/create-push-handler.cjs +1 -1
- package/dist/sw/create-push-handler.cjs.map +1 -1
- package/dist/sw/create-push-handler.js +20 -10
- package/dist/sw/create-push-handler.js.map +1 -1
- package/dist/sw.d.ts +65 -14
- package/dist/tabular/compact.cjs +1 -1
- package/dist/tabular/compact.cjs.map +1 -1
- package/dist/tabular/compact.js +35 -16
- package/dist/tabular/compact.js.map +1 -1
- package/dist/tabular/manifest.cjs +1 -1
- package/dist/tabular/manifest.cjs.map +1 -1
- package/dist/tabular/manifest.js +33 -15
- package/dist/tabular/manifest.js.map +1 -1
- package/dist/tabular.d.ts +11 -1
- package/dist/tempest-react-sdk.cjs +1 -1
- package/dist/tempest-react-sdk.d.ts +543 -70
- package/dist/tempest-react-sdk.js +224 -224
- package/dist/theme/initial-theme.cjs +4 -4
- package/dist/theme/initial-theme.cjs.map +1 -1
- package/dist/theme/initial-theme.js +8 -5
- package/dist/theme/initial-theme.js.map +1 -1
- package/dist/utils/compressed-storage.cjs +1 -1
- package/dist/utils/compressed-storage.cjs.map +1 -1
- package/dist/utils/compressed-storage.js +13 -7
- package/dist/utils/compressed-storage.js.map +1 -1
- package/dist/utils/objects.cjs +1 -1
- package/dist/utils/objects.cjs.map +1 -1
- package/dist/utils/objects.js +13 -7
- package/dist/utils/objects.js.map +1 -1
- package/dist/utils/safe-url.cjs +1 -1
- package/dist/utils/safe-url.cjs.map +1 -1
- package/dist/utils/safe-url.js +4 -1
- package/dist/utils/safe-url.js.map +1 -1
- package/dist/vite/base-url.cjs +1 -1
- package/dist/vite/base-url.cjs.map +1 -1
- package/dist/vite/base-url.js +5 -1
- package/dist/vite/base-url.js.map +1 -1
- package/dist/vite/tempest-pwa-dev-sw.cjs +1 -1
- package/dist/vite/tempest-pwa-dev-sw.cjs.map +1 -1
- package/dist/vite/tempest-pwa-dev-sw.js +96 -31
- package/dist/vite/tempest-pwa-dev-sw.js.map +1 -1
- package/dist/vite/tempest-pwa-manifest.cjs +2 -2
- package/dist/vite/tempest-pwa-manifest.cjs.map +1 -1
- package/dist/vite/tempest-pwa-manifest.js +32 -16
- package/dist/vite/tempest-pwa-manifest.js.map +1 -1
- package/dist/vite.cjs +1 -1
- package/dist/vite.d.ts +63 -12
- package/dist/vite.js +4 -4
- package/dist/webrtc/peer-mesh.cjs +1 -1
- package/dist/webrtc/peer-mesh.cjs.map +1 -1
- package/dist/webrtc/peer-mesh.js +7 -1
- package/dist/webrtc/peer-mesh.js.map +1 -1
- package/dist/ws/create-web-socket.cjs +1 -1
- package/dist/ws/create-web-socket.cjs.map +1 -1
- package/dist/ws/create-web-socket.js +77 -61
- package/dist/ws/create-web-socket.js.map +1 -1
- package/dist/ws/use-web-socket.cjs +1 -1
- package/dist/ws/use-web-socket.cjs.map +1 -1
- package/dist/ws/use-web-socket.js +4 -3
- package/dist/ws/use-web-socket.js.map +1 -1
- package/package.json +1 -1
- package/template-pwa/src/pwa.tsx +42 -0
- 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 {
|
|
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
|
-
*
|
|
167
|
+
* Whether a JSON value has an own key `__proto__` at any depth.
|
|
161
168
|
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
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)
|
|
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
|
+
});
|