@akinon/projectzero 2.0.20-rc.0 → 2.0.21-beta.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.
@@ -0,0 +1,77 @@
1
+ /**
2
+ * fix-login-form-action
3
+ *
4
+ * Rewrites the login form's `action="/api/auth/signin/credentials"` attribute
5
+ * in src/views/login/index.tsx to `action={() => {}}`.
6
+ *
7
+ * Why: under React 19 the form `action` prop is a function-action signal —
8
+ * passing one tells React itself to call event.preventDefault() before any
9
+ * listener runs. With the string URL it used to have, a hydration race
10
+ * (e.g. searchParams CSR bailout) would let the browser perform a native
11
+ * submit to the auth endpoint before the JS onSubmit listener attached.
12
+ * On the credentials login form that meant POSTing email and password as
13
+ * form-encoded body. The no-op function neutralizes that fallback while
14
+ * keeping the existing JS onSubmit as the real submit path.
15
+ *
16
+ * Brands without the attribute (or without src/views/login/index.tsx) are
17
+ * clean no-ops.
18
+ *
19
+ * Usage:
20
+ * node codemods/fix-login-form-action/index.js [--dry-run]
21
+ * npx @akinon/projectzero codemod --codemod=fix-login-form-action [--dry-run]
22
+ */
23
+
24
+ const path = require('path');
25
+ const fs = require('fs');
26
+ const jscodeshift = require('jscodeshift/src/Runner');
27
+
28
+ function log(msg) {
29
+ const ts = new Date().toISOString().replace('T', ' ').slice(0, 19);
30
+ console.log(`[${ts}] ${msg}`);
31
+ }
32
+
33
+ const transform = () => {
34
+ const cwd = path.resolve(process.cwd());
35
+ const dryRun = process.argv.includes('--dry-run');
36
+ const transformPath = path.join(__dirname, 'transform.js');
37
+ const target = path.join(cwd, 'src', 'views', 'login', 'index.tsx');
38
+
39
+ if (!fs.existsSync(target)) {
40
+ log(
41
+ `fix-login-form-action: no src/views/login/index.tsx at ${cwd}, skipping`
42
+ );
43
+ return Promise.resolve();
44
+ }
45
+
46
+ log(
47
+ `fix-login-form-action: scanning ${target}${dryRun ? ' (dry-run)' : ''}`
48
+ );
49
+
50
+ return jscodeshift
51
+ .run(transformPath, [target], {
52
+ verbose: 0,
53
+ dry: dryRun,
54
+ print: dryRun,
55
+ extensions: 'tsx',
56
+ parser: 'tsx',
57
+ ignorePattern: '**/node_modules/**',
58
+ silent: true
59
+ })
60
+ .then((res) => {
61
+ log(
62
+ `fix-login-form-action: ${res.ok} ok, ${res.nochange} unchanged, ${res.skip} skipped, ${res.error} errors`
63
+ );
64
+ });
65
+ };
66
+
67
+ module.exports = { transform };
68
+
69
+ if (require.main === module) {
70
+ const result = transform();
71
+ if (result && typeof result.then === 'function') {
72
+ result.catch((err) => {
73
+ console.error(err);
74
+ process.exit(1);
75
+ });
76
+ }
77
+ }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * jscodeshift transform for fix-login-form-action.
3
+ *
4
+ * Replaces every JSXAttribute named `action` whose value is the string
5
+ * "/api/auth/signin/credentials" with a no-op arrow function expression
6
+ * (`action={() => {}}`), and attaches an explanatory leading comment block.
7
+ *
8
+ * Returns null (no source change) when no matching attribute is found.
9
+ */
10
+ module.exports = function transform(fileInfo, api) {
11
+ const j = api.jscodeshift;
12
+ const root = j(fileInfo.source);
13
+ let changed = false;
14
+
15
+ root.find(j.JSXAttribute, { name: { name: 'action' } }).forEach((p) => {
16
+ const v = p.node.value;
17
+ if (!v) return;
18
+
19
+ // String values can show up as either Literal or StringLiteral depending
20
+ // on the parser version. Match both.
21
+ const isString = v.type === 'Literal' || v.type === 'StringLiteral';
22
+ if (!isString || v.value !== '/api/auth/signin/credentials') return;
23
+
24
+ const arrow = j.arrowFunctionExpression([], j.blockStatement([]));
25
+ p.node.value = j.jsxExpressionContainer(arrow);
26
+
27
+ // Leading block comment explains why the action is a no-op. Attaching
28
+ // to the attribute keeps the comment adjacent in the printed output.
29
+ const comment = j.commentBlock(
30
+ ' React 19 belt-and-suspenders: passing a function to `action` makes\n' +
31
+ ' React itself call preventDefault on the native submit before any\n' +
32
+ ' listener runs, so a hydration race cannot leak credentials into\n' +
33
+ ' the URL bar. onSubmit still drives the actual signIn flow. ',
34
+ true,
35
+ false
36
+ );
37
+ p.node.comments = [comment];
38
+
39
+ changed = true;
40
+ });
41
+
42
+ return changed ? root.toSource({ quote: 'single' }) : null;
43
+ };
@@ -0,0 +1,85 @@
1
+ /**
2
+ * fix-template-currenturl
3
+ *
4
+ * Rewrites the JSON-LD canonical url construction in
5
+ * src/app/** /template.tsx from
6
+ *
7
+ * const currentUrl = URL + pathname + searchParams;
8
+ *
9
+ * to
10
+ *
11
+ * const currentUrl = `${URL}${pathname}`;
12
+ *
13
+ * Why: in the Next 16 / React 19 model `searchParams` is a
14
+ * ReadonlyURLSearchParams that string-coerces differently on the server
15
+ * and the client. The mismatched output lands inside a JSON-LD
16
+ * <script dangerouslySetInnerHTML> tag, breaks hydration of the entire
17
+ * route subtree, and races sibling client component hydration — auth
18
+ * forms in particular have shipped credentials into the URL as native
19
+ * GETs because their onSubmit listener was not yet attached when the
20
+ * user clicked submit.
21
+ *
22
+ * Handles both base forms observed across brands:
23
+ * const currentUrl = URL + pathname + searchParams;
24
+ * const currentUrl = process.env.NEXT_PUBLIC_URL + pathname + searchParams;
25
+ *
26
+ * Files outside src/app/** /template.tsx are skipped. Files that already
27
+ * use the template-literal form are no-ops.
28
+ *
29
+ * Usage:
30
+ * node codemods/fix-template-currenturl/index.js [--dry-run]
31
+ * npx @akinon/projectzero codemod --codemod=fix-template-currenturl [--dry-run]
32
+ */
33
+
34
+ const path = require('path');
35
+ const fs = require('fs');
36
+ const jscodeshift = require('jscodeshift/src/Runner');
37
+
38
+ function log(msg) {
39
+ const ts = new Date().toISOString().replace('T', ' ').slice(0, 19);
40
+ console.log(`[${ts}] ${msg}`);
41
+ }
42
+
43
+ const transform = () => {
44
+ const cwd = path.resolve(process.cwd());
45
+ const dryRun = process.argv.includes('--dry-run');
46
+ const transformPath = path.join(__dirname, 'transform.js');
47
+ const srcPath = path.join(cwd, 'src', 'app');
48
+
49
+ if (!fs.existsSync(srcPath)) {
50
+ log(`fix-template-currenturl: no src/app at ${cwd}, skipping`);
51
+ return Promise.resolve();
52
+ }
53
+
54
+ log(
55
+ `fix-template-currenturl: scanning ${srcPath}${dryRun ? ' (dry-run)' : ''}`
56
+ );
57
+
58
+ return jscodeshift
59
+ .run(transformPath, [srcPath], {
60
+ verbose: 0,
61
+ dry: dryRun,
62
+ print: dryRun,
63
+ extensions: 'tsx',
64
+ parser: 'tsx',
65
+ ignorePattern: '**/node_modules/**',
66
+ silent: true
67
+ })
68
+ .then((res) => {
69
+ log(
70
+ `fix-template-currenturl: ${res.ok} ok, ${res.nochange} unchanged, ${res.skip} skipped, ${res.error} errors`
71
+ );
72
+ });
73
+ };
74
+
75
+ module.exports = { transform };
76
+
77
+ if (require.main === module) {
78
+ const result = transform();
79
+ if (result && typeof result.then === 'function') {
80
+ result.catch((err) => {
81
+ console.error(err);
82
+ process.exit(1);
83
+ });
84
+ }
85
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * jscodeshift transform for fix-template-currenturl.
3
+ *
4
+ * Matches:
5
+ * const currentUrl = (URL | process.env.NEXT_PUBLIC_URL) + pathname + searchParams;
6
+ *
7
+ * Replaces the right-hand side with the template literal
8
+ * `${X}${pathname}`
9
+ *
10
+ * Only runs against files whose path ends in `/app/**\/template.tsx`. Returns
11
+ * null (no source change) when no match is found in the file or the
12
+ * declarator already has a non-binary initializer.
13
+ */
14
+
15
+ function isUrlBase(node) {
16
+ if (!node) return false;
17
+ if (node.type === 'Identifier' && node.name === 'URL') return true;
18
+ if (
19
+ node.type === 'MemberExpression' &&
20
+ node.object &&
21
+ node.object.type === 'MemberExpression' &&
22
+ node.object.object &&
23
+ node.object.object.type === 'Identifier' &&
24
+ node.object.object.name === 'process' &&
25
+ node.object.property &&
26
+ node.object.property.type === 'Identifier' &&
27
+ node.object.property.name === 'env' &&
28
+ node.property &&
29
+ node.property.type === 'Identifier' &&
30
+ node.property.name === 'NEXT_PUBLIC_URL'
31
+ ) {
32
+ return true;
33
+ }
34
+ return false;
35
+ }
36
+
37
+ function flattenAdd(node, out) {
38
+ if (
39
+ node &&
40
+ node.type === 'BinaryExpression' &&
41
+ node.operator === '+'
42
+ ) {
43
+ flattenAdd(node.left, out);
44
+ flattenAdd(node.right, out);
45
+ } else {
46
+ out.push(node);
47
+ }
48
+ }
49
+
50
+ module.exports = function transform(fileInfo, api) {
51
+ // Restrict to template.tsx under any nested app/ segment. Brand routing
52
+ // depth varies (e.g. [commerce]/[locale]/[currency] vs deeper member-type
53
+ // splits) but the file name is constant.
54
+ if (!/[\\/]app[\\/].*template\.tsx$/.test(fileInfo.path)) {
55
+ return null;
56
+ }
57
+
58
+ const j = api.jscodeshift;
59
+ const root = j(fileInfo.source);
60
+ let changed = false;
61
+
62
+ root
63
+ .find(j.VariableDeclarator, { id: { name: 'currentUrl' } })
64
+ .forEach((p) => {
65
+ const init = p.node.init;
66
+ if (
67
+ !init ||
68
+ init.type !== 'BinaryExpression' ||
69
+ init.operator !== '+'
70
+ ) {
71
+ return;
72
+ }
73
+
74
+ const parts = [];
75
+ flattenAdd(init, parts);
76
+ if (parts.length !== 3) return;
77
+
78
+ const [base, mid, tail] = parts;
79
+ if (mid.type !== 'Identifier' || mid.name !== 'pathname') return;
80
+ if (tail.type !== 'Identifier' || tail.name !== 'searchParams') return;
81
+ if (!isUrlBase(base)) return;
82
+
83
+ // Build: `${base}${pathname}`
84
+ const empty = () => j.templateElement({ raw: '', cooked: '' }, false);
85
+ const tail2 = j.templateElement({ raw: '', cooked: '' }, true);
86
+ p.node.init = j.templateLiteral([empty(), empty(), tail2], [base, mid]);
87
+ changed = true;
88
+ });
89
+
90
+ return changed ? root.toSource({ quote: 'single' }) : null;
91
+ };