diff --git a/package.json b/package.json index 56b7f49e..d7c509fd 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,8 @@ "@tanstack/react-query-devtools": "^5.77.0", "@vanilla-extract/css": "^1.17.2", "@vanilla-extract/recipes": "^0.5.7", + "iron-session": "^8.0.4", + "ky": "^1.8.1", "next": "15.3.2", "react": "^19.0.0", "react-dom": "^19.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2667fc5c..15b6163a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,12 @@ importers: '@vanilla-extract/recipes': specifier: ^0.5.7 version: 0.5.7(@vanilla-extract/css@1.17.2) + iron-session: + specifier: ^8.0.4 + version: 8.0.4 + ky: + specifier: ^1.8.1 + version: 1.8.1 next: specifier: 15.3.2 version: 15.3.2(@babel/core@7.27.1)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -92,7 +98,7 @@ importers: version: 2.4.11(next@15.3.2(@babel/core@7.27.1)(@playwright/test@1.52.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(webpack@5.99.9(esbuild@0.25.4)) '@vanilla-extract/vite-plugin': specifier: ^5.0.7 - version: 5.0.7(@types/node@20.17.50)(jiti@2.4.2)(terser@5.39.2)(vite@6.3.5(@types/node@20.17.50)(jiti@2.4.2)(terser@5.39.2)(yaml@2.8.0))(yaml@2.8.0) + version: 5.1.0(@types/node@20.17.50)(jiti@2.4.2)(terser@5.39.2)(vite@6.3.5(@types/node@20.17.50)(jiti@2.4.2)(terser@5.39.2)(yaml@2.8.0))(yaml@2.8.0) '@vitejs/plugin-react': specifier: ^4.5.0 version: 4.5.0(vite@6.3.5(@types/node@20.17.50)(jiti@2.4.2)(terser@5.39.2)(yaml@2.8.0)) @@ -189,6 +195,10 @@ packages: resolution: {integrity: sha512-TUtMJYRPyUb/9aU8f3K0mjmjf6M9N5Woshn2CS6nqJSeJtTtQcpLUXjGt9vbF8ZGff0El99sWkLgzwW3VXnxZQ==} engines: {node: '>=6.9.0'} + '@babel/compat-data@7.27.7': + resolution: {integrity: sha512-xgu/ySj2mTiUFmdE9yCMfBxLp4DHd5DwmbbD05YAuICfodYT3VvRxbrh81LGQ/8UpSdtMdfKMn3KouYDX59DGQ==} + engines: {node: '>=6.9.0'} + '@babel/core@7.27.1': resolution: {integrity: sha512-IaaGWsQqfsQWVLqMn9OB92MNN7zukfVA4s7KKAI0KfrrDsZ0yhi5uV4baBuLuN7n3vsZpwP8asPPcVwApxvjBQ==} engines: {node: '>=6.9.0'} @@ -197,6 +207,10 @@ packages: resolution: {integrity: sha512-UnJfnIpc/+JO0/+KRVQNGU+y5taA5vCbwN8+azkX6beii/ZF+enZJSOKo11ZSzGJjlNfJHfQtmQT8H+9TXPG2w==} engines: {node: '>=6.9.0'} + '@babel/generator@7.27.5': + resolution: {integrity: sha512-ZGhA37l0e/g2s1Cnzdix0O3aLYm66eF8aufiVteOgnwxgnRP8GoyMj7VWsgWnQbVKXyge7hqrFh2K2TQM6t1Hw==} + engines: {node: '>=6.9.0'} + '@babel/helper-annotate-as-pure@7.27.3': resolution: {integrity: sha512-fXSwMQqitTGeHLBC08Eq5yXz2m37E4pJX1qAU1+2cNedz/ifv/bVXft90VeSav5nFO61EcNgwr0aJxbyPaWBPg==} engines: {node: '>=6.9.0'} @@ -217,8 +231,8 @@ packages: peerDependencies: '@babel/core': ^7.0.0 - '@babel/helper-define-polyfill-provider@0.6.4': - resolution: {integrity: sha512-jljfR1rGnXXNWnmQg2K3+bvhkxB51Rl32QRaOTuwwjviGrHzIbSc8+x9CpraDtbT7mfyjXObULP4w/adunNwAw==} + '@babel/helper-define-polyfill-provider@0.6.5': + resolution: {integrity: sha512-uJnGFcPsWQK8fvjgGP5LZUZZsYGIoPeRjSF5PGwrelYgq7Q15/Ft9NGFp1zglwgIv//W0uG4BevRuSJRyylZPg==} peerDependencies: '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 @@ -285,6 +299,11 @@ packages: engines: {node: '>=6.0.0'} hasBin: true + '@babel/parser@7.27.7': + resolution: {integrity: sha512-qnzXzDXdr/po3bOTbTIQZ7+TxNKxpkN5IifVLXS+r7qwynkZfPyjZfE7hCXbo7IoO9TNcSyibgONsf2HauUd3Q==} + engines: {node: '>=6.0.0'} + hasBin: true + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.27.1': resolution: {integrity: sha512-QPG3C9cCVRQLxAVwmefEmwdTanECuUBMQZ/ym5kiw3XKCGA7qkuQLcjWWHcrD/GKbn/WmJwaezfuuAOcyKlRPA==} engines: {node: '>=6.9.0'} @@ -393,8 +412,8 @@ packages: peerDependencies: '@babel/core': ^7.12.0 - '@babel/plugin-transform-classes@7.27.1': - resolution: {integrity: sha512-7iLhfFAubmpeJe/Wo2TVuDrykh/zlWXLzPNdL0Jqn/Xu8R3QQ8h9ff8FQoISZOsw74/HFqFI7NX63HN7QFIHKA==} + '@babel/plugin-transform-classes@7.27.7': + resolution: {integrity: sha512-CuLkokN1PEZ0Fsjtq+001aog/C2drDK9nTfK/NRK0n6rBin6cBrvM+zfQjDE+UllhR6/J4a6w8Xq9i4yi3mQrw==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 @@ -405,8 +424,8 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-destructuring@7.27.3': - resolution: {integrity: sha512-s4Jrok82JpiaIprtY2nHsYmrThKvvwgHwjgd7UMiYhZaN0asdXNLr0y+NjTfkA7SyQE5i2Fb7eawUOZmLvyqOA==} + '@babel/plugin-transform-destructuring@7.27.7': + resolution: {integrity: sha512-pg3ZLdIKWCP0CrJm0O4jYjVthyBeioVfvz9nwt6o5paUxsgJ/8GucSMAIaj6M7xA4WY+SrvtGu2LijzkdyecWQ==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 @@ -531,8 +550,8 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-object-rest-spread@7.27.3': - resolution: {integrity: sha512-7ZZtznF9g4l2JCImCo5LNKFHB5eXnN39lLtLY5Tg+VkR0jwOt7TBciMckuiQIOIW7L5tkQOCh3bVGYeXgMx52Q==} + '@babel/plugin-transform-object-rest-spread@7.27.7': + resolution: {integrity: sha512-201B1kFTWhckclcXpWHc8uUpYziDX/Pl4rxl0ZX0DiCZ3jknwfSUALL3QCYeeXXB37yWxJbo+g+Vfq8pAaHi3w==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 @@ -555,8 +574,8 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/plugin-transform-parameters@7.27.1': - resolution: {integrity: sha512-018KRk76HWKeZ5l4oTj2zPpSh+NbGdt0st5S6x0pga6HgrjBOJb24mMDHorFopOOd6YHkLgOZ+zaCjZGPO4aKg==} + '@babel/plugin-transform-parameters@7.27.7': + resolution: {integrity: sha512-qBkYTYCb76RRxUM6CcZA5KRu8K4SM8ajzVeUgVdMVO9NN9uI/GaVmBg/WKJJGnNokV9SY8FxNOVWGXzqzUidBg==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 @@ -734,12 +753,16 @@ packages: resolution: {integrity: sha512-ZCYtZciz1IWJB4U61UPu4KEaqyfj+r5T1Q5mqPo+IBpcG9kHv30Z0aD8LXPgC1trYa6rK0orRyAhqUgk4MjmEg==} engines: {node: '>=6.9.0'} + '@babel/traverse@7.27.7': + resolution: {integrity: sha512-X6ZlfR/O/s5EQ/SnUSLzr+6kGnkg8HXGMzpgsMsrJVcfDtH1vIp6ctCN4eZ1LS5c0+te5Cb6Y514fASjMRJ1nw==} + engines: {node: '>=6.9.0'} + '@babel/types@7.27.1': resolution: {integrity: sha512-+EzkxvLNfiUeKMgy/3luqfsCWFRXLb7U6wNQTk60tovuckwB15B191tJWvpp4HjiQWdJkCxO3Wbvc6jlk3Xb2Q==} engines: {node: '>=6.9.0'} - '@babel/types@7.27.6': - resolution: {integrity: sha512-ETyHEk2VHHvl9b9jZP5IHPavHYk57EhanlRRuae9XCpb/j5bDCbPPMOBfCWhnl/7EDJz0jEMCi/RhccCE8r1+Q==} + '@babel/types@7.27.7': + resolution: {integrity: sha512-8OLQgDScAOHXnAz2cV+RfzzNMipuLVBz2biuAJFMV9bfkNf393je3VM8CLkjQodW5+iWsSJdSgSWT6rsZoXHPw==} engines: {node: '>=6.9.0'} '@bcoe/v8-coverage@1.0.2': @@ -2220,8 +2243,8 @@ packages: '@vanilla-extract/babel-plugin-debug-ids@1.2.2': resolution: {integrity: sha512-MeDWGICAF9zA/OZLOKwhoRlsUW+fiMwnfuOAqFVohL31Agj7Q/RBWAYweqjHLgFBCsdnr6XIfwjJnmb2znEWxw==} - '@vanilla-extract/compiler@0.2.3': - resolution: {integrity: sha512-SFEDLbvd5rhpjhrLp9BtvvVNHNxWupiUht/yrsHQ7xfkpEn4xg45gbfma7aX9fsOpi82ebqFmowHd/g6jHDQnA==} + '@vanilla-extract/compiler@0.3.0': + resolution: {integrity: sha512-8EbPmDMXhY9NrN38Kh8xYDENgBk4i6s6ce4p7E9F3kHtCqxtEgfaKSNS08z/SVCTmaX3IB3N/kGSO0gr+APffg==} '@vanilla-extract/css@1.17.2': resolution: {integrity: sha512-gowpfR1zJSplDO7NkGf2Vnw9v9eG1P3aUlQpxa1pOjcknbgWw7UPzIboB6vGJZmoUvDZRFmipss3/Q+RRfhloQ==} @@ -2251,8 +2274,8 @@ packages: peerDependencies: '@vanilla-extract/css': ^1.0.0 - '@vanilla-extract/vite-plugin@5.0.7': - resolution: {integrity: sha512-UyUma9HEl2qHl0CFlTwPKU7SkyuOwNQOgLT1yMShjqiQqg8k+9oma2Z5GigGtEUhUDizCAxeR4b4bP9KAviDdQ==} + '@vanilla-extract/vite-plugin@5.1.0': + resolution: {integrity: sha512-BzVdmBD+FUyJnY6I29ZezwtDBc1B78l+VvHvIgoJYbgfPj0hvY0RmrGL8B4oNNGY/lOt7KgQflXY5kBMd3MGZg==} peerDependencies: vite: ^5.0.0 || ^6.0.0 @@ -2546,8 +2569,8 @@ packages: resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} engines: {node: '>= 0.4'} - babel-plugin-polyfill-corejs2@0.4.13: - resolution: {integrity: sha512-3sX/eOms8kd3q2KZ6DAhKPc0dgm525Gqq5NtWKZ7QYYZEv57OQ54KtblzJzH1lQF/eQxO8KjWGIK9IPUJNus5g==} + babel-plugin-polyfill-corejs2@0.4.14: + resolution: {integrity: sha512-Co2Y9wX854ts6U8gAAPXfn0GmAyctHuK8n0Yhfjd6t30g7yvKjspvvOo9yG+z52PZRgFErt7Ka2pYnXCjLKEpg==} peerDependencies: '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 @@ -2556,8 +2579,8 @@ packages: peerDependencies: '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 - babel-plugin-polyfill-regenerator@0.6.4: - resolution: {integrity: sha512-7gD3pRadPrbjhjLyxebmx/WrFYcuSjZ0XbdUujQMZ/fcE9oeewk2U/7PCvez84UeuK3oSjmPZ0Ch0dlupQvGzw==} + babel-plugin-polyfill-regenerator@0.6.5: + resolution: {integrity: sha512-ISqQ2frbiNU9vIJkzg7dlPpznPZ4jOiUQ1uSmB0fEHeowtN3COYRsXr/xexn64NpU13P06jc/L5TgiJXOgrbEg==} peerDependencies: '@babel/core': ^7.4.0 || ^8.0.0-0 <8.0.0 @@ -2829,8 +2852,8 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} - css-select@5.1.0: - resolution: {integrity: sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==} + css-select@5.2.2: + resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} css-tree@2.2.1: resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==} @@ -3010,8 +3033,8 @@ packages: electron-to-chromium@1.5.157: resolution: {integrity: sha512-/0ybgsQd1muo8QlnuTpKwtl0oX5YMlUGbm8xyqgDU00motRkKFFbUJySAQBWcY79rVqNLWIWa87BGVGClwAB2w==} - electron-to-chromium@1.5.175: - resolution: {integrity: sha512-Nqpef9mOVo7pZfl9NIUhj7tgtRTsMzCzRTJDP1ccim4Wb4YHOz3Le87uxeZq68OCNwau2iQ/X7UwdAZ3ReOkmg==} + electron-to-chromium@1.5.178: + resolution: {integrity: sha512-wObbz/ar3Bc6e4X5vf0iO8xTN8YAjN/tgiAOJLr7yjYFtP9wAjq8Mb5h0yn6kResir+VYx2DXBj9NNobs0ETSA==} emoji-regex@10.4.0: resolution: {integrity: sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==} @@ -3619,6 +3642,12 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + iron-session@8.0.4: + resolution: {integrity: sha512-9ivNnaKOd08osD0lJ3i6If23GFS2LsxyMU8Gf/uBUEgm8/8CC1hrrCHFDpMo3IFbpBgwoo/eairRsaD3c5itxA==} + + iron-webcrypto@1.2.1: + resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} + is-arguments@1.2.0: resolution: {integrity: sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==} engines: {node: '>= 0.4'} @@ -3886,6 +3915,10 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + ky@1.8.1: + resolution: {integrity: sha512-7Bp3TpsE+L+TARSnnDpk3xg8Idi8RwSLdj6CMbNWoOARIrGrbuLGusV0dYwbZOm4bB3jHNxSw8Wk/ByDqJEnDw==} + engines: {node: '>=18'} + language-subtag-registry@0.3.23: resolution: {integrity: sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==} @@ -5034,6 +5067,9 @@ packages: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} + uncrypto@0.1.3: + resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} + undici-types@6.19.8: resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} @@ -5366,6 +5402,8 @@ snapshots: '@babel/compat-data@7.27.2': {} + '@babel/compat-data@7.27.7': {} + '@babel/core@7.27.1': dependencies: '@ampproject/remapping': 2.3.0 @@ -5394,9 +5432,17 @@ snapshots: '@jridgewell/trace-mapping': 0.3.25 jsesc: 3.1.0 + '@babel/generator@7.27.5': + dependencies: + '@babel/parser': 7.27.7 + '@babel/types': 7.27.7 + '@jridgewell/gen-mapping': 0.3.8 + '@jridgewell/trace-mapping': 0.3.25 + jsesc: 3.1.0 + '@babel/helper-annotate-as-pure@7.27.3': dependencies: - '@babel/types': 7.27.6 + '@babel/types': 7.27.7 '@babel/helper-compilation-targets@7.27.2': dependencies: @@ -5426,7 +5472,7 @@ snapshots: regexpu-core: 6.2.0 semver: 6.3.1 - '@babel/helper-define-polyfill-provider@0.6.4(@babel/core@7.27.1)': + '@babel/helper-define-polyfill-provider@0.6.5(@babel/core@7.27.1)': dependencies: '@babel/core': 7.27.1 '@babel/helper-compilation-targets': 7.27.2 @@ -5514,6 +5560,10 @@ snapshots: dependencies: '@babel/types': 7.27.1 + '@babel/parser@7.27.7': + dependencies: + '@babel/types': 7.27.7 + '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.27.1(@babel/core@7.27.1)': dependencies: '@babel/core': 7.27.1 @@ -5628,14 +5678,14 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-transform-classes@7.27.1(@babel/core@7.27.1)': + '@babel/plugin-transform-classes@7.27.7(@babel/core@7.27.1)': dependencies: '@babel/core': 7.27.1 '@babel/helper-annotate-as-pure': 7.27.3 '@babel/helper-compilation-targets': 7.27.2 '@babel/helper-plugin-utils': 7.27.1 '@babel/helper-replace-supers': 7.27.1(@babel/core@7.27.1) - '@babel/traverse': 7.27.1 + '@babel/traverse': 7.27.7 globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -5646,10 +5696,13 @@ snapshots: '@babel/helper-plugin-utils': 7.27.1 '@babel/template': 7.27.2 - '@babel/plugin-transform-destructuring@7.27.3(@babel/core@7.27.1)': + '@babel/plugin-transform-destructuring@7.27.7(@babel/core@7.27.1)': dependencies: '@babel/core': 7.27.1 '@babel/helper-plugin-utils': 7.27.1 + '@babel/traverse': 7.27.7 + transitivePeerDependencies: + - supports-color '@babel/plugin-transform-dotall-regex@7.27.1(@babel/core@7.27.1)': dependencies: @@ -5775,13 +5828,16 @@ snapshots: '@babel/core': 7.27.1 '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-transform-object-rest-spread@7.27.3(@babel/core@7.27.1)': + '@babel/plugin-transform-object-rest-spread@7.27.7(@babel/core@7.27.1)': dependencies: '@babel/core': 7.27.1 '@babel/helper-compilation-targets': 7.27.2 '@babel/helper-plugin-utils': 7.27.1 - '@babel/plugin-transform-destructuring': 7.27.3(@babel/core@7.27.1) - '@babel/plugin-transform-parameters': 7.27.1(@babel/core@7.27.1) + '@babel/plugin-transform-destructuring': 7.27.7(@babel/core@7.27.1) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.27.1) + '@babel/traverse': 7.27.7 + transitivePeerDependencies: + - supports-color '@babel/plugin-transform-object-super@7.27.1(@babel/core@7.27.1)': dependencies: @@ -5804,7 +5860,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/plugin-transform-parameters@7.27.1(@babel/core@7.27.1)': + '@babel/plugin-transform-parameters@7.27.7(@babel/core@7.27.1)': dependencies: '@babel/core': 7.27.1 '@babel/helper-plugin-utils': 7.27.1 @@ -5976,9 +6032,9 @@ snapshots: '@babel/plugin-transform-block-scoping': 7.27.5(@babel/core@7.27.1) '@babel/plugin-transform-class-properties': 7.27.1(@babel/core@7.27.1) '@babel/plugin-transform-class-static-block': 7.27.1(@babel/core@7.27.1) - '@babel/plugin-transform-classes': 7.27.1(@babel/core@7.27.1) + '@babel/plugin-transform-classes': 7.27.7(@babel/core@7.27.1) '@babel/plugin-transform-computed-properties': 7.27.1(@babel/core@7.27.1) - '@babel/plugin-transform-destructuring': 7.27.3(@babel/core@7.27.1) + '@babel/plugin-transform-destructuring': 7.27.7(@babel/core@7.27.1) '@babel/plugin-transform-dotall-regex': 7.27.1(@babel/core@7.27.1) '@babel/plugin-transform-duplicate-keys': 7.27.1(@babel/core@7.27.1) '@babel/plugin-transform-duplicate-named-capturing-groups-regex': 7.27.1(@babel/core@7.27.1) @@ -5999,11 +6055,11 @@ snapshots: '@babel/plugin-transform-new-target': 7.27.1(@babel/core@7.27.1) '@babel/plugin-transform-nullish-coalescing-operator': 7.27.1(@babel/core@7.27.1) '@babel/plugin-transform-numeric-separator': 7.27.1(@babel/core@7.27.1) - '@babel/plugin-transform-object-rest-spread': 7.27.3(@babel/core@7.27.1) + '@babel/plugin-transform-object-rest-spread': 7.27.7(@babel/core@7.27.1) '@babel/plugin-transform-object-super': 7.27.1(@babel/core@7.27.1) '@babel/plugin-transform-optional-catch-binding': 7.27.1(@babel/core@7.27.1) '@babel/plugin-transform-optional-chaining': 7.27.1(@babel/core@7.27.1) - '@babel/plugin-transform-parameters': 7.27.1(@babel/core@7.27.1) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.27.1) '@babel/plugin-transform-private-methods': 7.27.1(@babel/core@7.27.1) '@babel/plugin-transform-private-property-in-object': 7.27.1(@babel/core@7.27.1) '@babel/plugin-transform-property-literals': 7.27.1(@babel/core@7.27.1) @@ -6020,9 +6076,9 @@ snapshots: '@babel/plugin-transform-unicode-regex': 7.27.1(@babel/core@7.27.1) '@babel/plugin-transform-unicode-sets-regex': 7.27.1(@babel/core@7.27.1) '@babel/preset-modules': 0.1.6-no-external-plugins(@babel/core@7.27.1) - babel-plugin-polyfill-corejs2: 0.4.13(@babel/core@7.27.1) + babel-plugin-polyfill-corejs2: 0.4.14(@babel/core@7.27.1) babel-plugin-polyfill-corejs3: 0.11.1(@babel/core@7.27.1) - babel-plugin-polyfill-regenerator: 0.6.4(@babel/core@7.27.1) + babel-plugin-polyfill-regenerator: 0.6.5(@babel/core@7.27.1) core-js-compat: 3.43.0 semver: 6.3.1 transitivePeerDependencies: @@ -6078,12 +6134,24 @@ snapshots: transitivePeerDependencies: - supports-color + '@babel/traverse@7.27.7': + dependencies: + '@babel/code-frame': 7.27.1 + '@babel/generator': 7.27.5 + '@babel/parser': 7.27.7 + '@babel/template': 7.27.2 + '@babel/types': 7.27.7 + debug: 4.4.1 + globals: 11.12.0 + transitivePeerDependencies: + - supports-color + '@babel/types@7.27.1': dependencies: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.27.1 - '@babel/types@7.27.6': + '@babel/types@7.27.7': dependencies: '@babel/helper-string-parser': 7.27.1 '@babel/helper-validator-identifier': 7.27.1 @@ -7496,7 +7564,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@vanilla-extract/compiler@0.2.3(@types/node@20.17.50)(jiti@2.4.2)(terser@5.39.2)(yaml@2.8.0)': + '@vanilla-extract/compiler@0.3.0(@types/node@20.17.50)(jiti@2.4.2)(terser@5.39.2)(yaml@2.8.0)': dependencies: '@vanilla-extract/css': 1.17.4 '@vanilla-extract/integration': 8.0.4 @@ -7600,9 +7668,9 @@ snapshots: dependencies: '@vanilla-extract/css': 1.17.2 - '@vanilla-extract/vite-plugin@5.0.7(@types/node@20.17.50)(jiti@2.4.2)(terser@5.39.2)(vite@6.3.5(@types/node@20.17.50)(jiti@2.4.2)(terser@5.39.2)(yaml@2.8.0))(yaml@2.8.0)': + '@vanilla-extract/vite-plugin@5.1.0(@types/node@20.17.50)(jiti@2.4.2)(terser@5.39.2)(vite@6.3.5(@types/node@20.17.50)(jiti@2.4.2)(terser@5.39.2)(yaml@2.8.0))(yaml@2.8.0)': dependencies: - '@vanilla-extract/compiler': 0.2.3(@types/node@20.17.50)(jiti@2.4.2)(terser@5.39.2)(yaml@2.8.0) + '@vanilla-extract/compiler': 0.3.0(@types/node@20.17.50)(jiti@2.4.2)(terser@5.39.2)(yaml@2.8.0) '@vanilla-extract/integration': 8.0.4 vite: 6.3.5(@types/node@20.17.50)(jiti@2.4.2)(terser@5.39.2)(yaml@2.8.0) transitivePeerDependencies: @@ -8000,11 +8068,11 @@ snapshots: axobject-query@4.1.0: {} - babel-plugin-polyfill-corejs2@0.4.13(@babel/core@7.27.1): + babel-plugin-polyfill-corejs2@0.4.14(@babel/core@7.27.1): dependencies: - '@babel/compat-data': 7.27.2 + '@babel/compat-data': 7.27.7 '@babel/core': 7.27.1 - '@babel/helper-define-polyfill-provider': 0.6.4(@babel/core@7.27.1) + '@babel/helper-define-polyfill-provider': 0.6.5(@babel/core@7.27.1) semver: 6.3.1 transitivePeerDependencies: - supports-color @@ -8012,15 +8080,15 @@ snapshots: babel-plugin-polyfill-corejs3@0.11.1(@babel/core@7.27.1): dependencies: '@babel/core': 7.27.1 - '@babel/helper-define-polyfill-provider': 0.6.4(@babel/core@7.27.1) + '@babel/helper-define-polyfill-provider': 0.6.5(@babel/core@7.27.1) core-js-compat: 3.43.0 transitivePeerDependencies: - supports-color - babel-plugin-polyfill-regenerator@0.6.4(@babel/core@7.27.1): + babel-plugin-polyfill-regenerator@0.6.5(@babel/core@7.27.1): dependencies: '@babel/core': 7.27.1 - '@babel/helper-define-polyfill-provider': 0.6.4(@babel/core@7.27.1) + '@babel/helper-define-polyfill-provider': 0.6.5(@babel/core@7.27.1) transitivePeerDependencies: - supports-color @@ -8067,7 +8135,7 @@ snapshots: browserslist@4.25.1: dependencies: caniuse-lite: 1.0.30001726 - electron-to-chromium: 1.5.175 + electron-to-chromium: 1.5.178 node-releases: 2.0.19 update-browserslist-db: 1.1.3(browserslist@4.25.1) @@ -8291,7 +8359,7 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 - css-select@5.1.0: + css-select@5.2.2: dependencies: boolbase: 1.0.0 css-what: 6.1.0 @@ -8465,7 +8533,7 @@ snapshots: electron-to-chromium@1.5.157: {} - electron-to-chromium@1.5.175: {} + electron-to-chromium@1.5.178: {} emoji-regex@10.4.0: {} @@ -9242,6 +9310,14 @@ snapshots: hasown: 2.0.2 side-channel: 1.1.0 + iron-session@8.0.4: + dependencies: + cookie: 0.7.2 + iron-webcrypto: 1.2.1 + uncrypto: 0.1.3 + + iron-webcrypto@1.2.1: {} + is-arguments@1.2.0: dependencies: call-bound: 1.0.4 @@ -9517,6 +9593,8 @@ snapshots: dependencies: json-buffer: 3.0.1 + ky@1.8.1: {} + language-subtag-registry@0.3.23: {} language-tags@1.0.9: @@ -10586,7 +10664,7 @@ snapshots: dependencies: '@trysound/sax': 0.2.0 commander: 7.2.0 - css-select: 5.1.0 + css-select: 5.2.2 css-tree: 2.3.1 css-what: 6.1.0 csso: 5.0.5 @@ -10755,6 +10833,8 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 + uncrypto@0.1.3: {} + undici-types@6.19.8: {} unicode-canonical-property-names-ecmascript@2.0.1: {} @@ -10994,7 +11074,7 @@ snapshots: '@webassemblyjs/wasm-edit': 1.14.1 '@webassemblyjs/wasm-parser': 1.14.1 acorn: 8.14.1 - browserslist: 4.25.1 + browserslist: 4.24.5 chrome-trace-event: 1.0.4 enhanced-resolve: 5.18.1 es-module-lexer: 1.7.0 diff --git a/src/app/(auth)/_api/auth/auth.api.ts b/src/app/(auth)/_api/auth/auth.api.ts new file mode 100644 index 00000000..46fe9a99 --- /dev/null +++ b/src/app/(auth)/_api/auth/auth.api.ts @@ -0,0 +1,76 @@ +import { http, nextHttp } from "@/lib/api/client"; + +import type { + LoginRequest, + LoginResponse, + ReissueRequest, + ReissueResponse, +} from "./auth.types"; + +/** + * 백엔드의 /api/auth/login 엔드포인트에 로그인 요청을 보냅니다. + * + * @param {LoginRequest} params - 카카오 인가 코드 + * @returns {Promise} 로그인 응답 데이터 + */ +export const postLogin = async (params: LoginRequest) => { + return await http + .post("api/auth/login", { json: params }) + .json(); +}; + +/** + * 백엔드의 /api/auth/reissue 엔드포인트에 토큰 재발급 요청을 보냅니다. + * + * @param {ReissueRequest} params - 리프레시 토큰 + * @returns {Promise} 재발급된 토큰 데이터 + */ +export const postReissue = async (params: ReissueRequest) => { + return await http + .post("api/auth/reissue", { json: params }) + .json(); +}; + +/** + * OAuth 제공자의 인증 페이지로 브라우저를 리다이렉트시킵니다. + * + * @description + * 이 함수를 호출하면, 서버로부터 302 리다이렉트 응답을 받아 + * OAuth 제공자의 인증 페이지로 즉시 이동합니다. + * 반환 값은 없으며, 호출 즉시 페이지가 전환됩니다. + */ +export const redirectToKakaoOAuthLoginPage = async () => { + window.location.href = `${process.env.NEXT_PUBLIC_API_URL}/api/auth/login/oauth`; +}; + +type Information = Omit["information"]; + +/** + * Next.js API Route(/api/auth/login)를 통해 로그인 요청을 보냅니다. + * + * @param {LoginRequest} params - 카카오 인가 코드 + * @returns {Promise} 회원 정보 + */ +export const postClientLogin = async (params: Omit) => { + return await nextHttp + .post("api/auth/login", { json: params }) + .json(); +}; + +/** + * Next.js API Route(/api/auth/reissue)를 통해 토큰 재발급 요청을 보냅니다. + * + * @returns {Promise} 재발급된 세션 정보 + */ +export const postClientReissue = async () => { + return await nextHttp.post("api/auth/reissue").json(); +}; + +/** + * Next.js API Route(/api/auth/logout)를 통해 세션 삭제(로그아웃) 요청을 보냅니다. + * + * @returns {Promise} 로그아웃 결과 + */ +export const deleteClientSession = async () => { + return await nextHttp.delete("api/auth/logout").json(); +}; diff --git a/src/app/(auth)/_api/auth/auth.queries.ts b/src/app/(auth)/_api/auth/auth.queries.ts new file mode 100644 index 00000000..390b5401 --- /dev/null +++ b/src/app/(auth)/_api/auth/auth.queries.ts @@ -0,0 +1,25 @@ +import { useMutation } from "@tanstack/react-query"; + +import { + deleteClientSession, + postClientLogin, + postClientReissue, +} from "./auth.api"; + +export const useLoginMutation = () => { + return useMutation({ + mutationFn: postClientLogin, + }); +}; + +export const useReissueMutation = () => { + return useMutation({ + mutationFn: postClientReissue, + }); +}; + +export const useDeleteSessionMutation = () => { + return useMutation({ + mutationFn: deleteClientSession, + }); +}; diff --git a/src/app/(auth)/_api/auth/auth.types.ts b/src/app/(auth)/_api/auth/auth.types.ts new file mode 100644 index 00000000..91a9de4c --- /dev/null +++ b/src/app/(auth)/_api/auth/auth.types.ts @@ -0,0 +1,24 @@ +export type LoginRequest = { + code: string; + origin: string; +}; + +export type LoginResponse = { + token: { + accessToken: string; + refreshToken: string; + }; + information: { + id: number; + isSignUp: boolean; + }; +}; + +export type ReissueRequest = { + refreshToken: string; +}; + +export type ReissueResponse = { + accessToken: string; + refreshToken: string; +}; diff --git a/src/app/(auth)/_api/session/session.api.ts b/src/app/(auth)/_api/session/session.api.ts new file mode 100644 index 00000000..56ca4baf --- /dev/null +++ b/src/app/(auth)/_api/session/session.api.ts @@ -0,0 +1,12 @@ +import { nextHttp } from "@/lib/api/client"; + +import type { SessionData } from "./session.types"; + +/** + * Next.js API Route(/api/session)를 통해 세션 정보를 요청합니다. + * + * @returns {Promise} 세션 정보 + */ +export const getSession = async () => { + return await nextHttp.get("api/session").json(); +}; diff --git a/src/app/(auth)/_api/session/session.queries.ts b/src/app/(auth)/_api/session/session.queries.ts new file mode 100644 index 00000000..975286ee --- /dev/null +++ b/src/app/(auth)/_api/session/session.queries.ts @@ -0,0 +1,14 @@ +import { queryOptions } from "@tanstack/react-query"; + +import { getSession } from "./session.api"; + +const sessionQueryKeys = { + session: ["session"], +}; + +export const sessionQueries = { + session: queryOptions({ + queryKey: sessionQueryKeys.session, + queryFn: getSession, + }), +}; diff --git a/src/app/(auth)/_api/session/session.types.ts b/src/app/(auth)/_api/session/session.types.ts new file mode 100644 index 00000000..b6c9fc19 --- /dev/null +++ b/src/app/(auth)/_api/session/session.types.ts @@ -0,0 +1 @@ +export { type SessionData } from "@/lib/session"; diff --git a/src/app/(auth)/login/callback/page.tsx b/src/app/(auth)/login/callback/page.tsx new file mode 100644 index 00000000..6ead947c --- /dev/null +++ b/src/app/(auth)/login/callback/page.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useRouter, useSearchParams } from "next/navigation"; +import { useEffect } from "react"; + +import { useLoginMutation } from "@/app/(auth)/_api/auth/auth.queries"; +import { clearClientSessionCache } from "@/lib/session"; + +export default function AuthCallbackPage() { + const router = useRouter(); + const searchParams = useSearchParams(); + const code = searchParams.get("code"); + const next = searchParams.get("next"); + + const { mutate: login } = useLoginMutation(); + + useEffect(() => { + if (code) { + login( + { code }, + { + onSuccess: () => { + clearClientSessionCache(); + + const redirectUrl = next || "/"; + + router.replace(redirectUrl); + }, + onError: error => { + console.error("로그인에 실패했습니다:", error); + alert("로그인에 실패했습니다. 다시 시도해주세요."); + router.replace("/login"); + }, + } + ); + } else { + alert("비정상적인 접근입니다."); + router.replace("/"); + } + }, [code, login, router, next]); + + return
로그인 중입니다...
; +} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts new file mode 100644 index 00000000..21641303 --- /dev/null +++ b/src/app/api/auth/login/route.ts @@ -0,0 +1,58 @@ +import { type NextRequest, NextResponse } from "next/server"; + +import { postLogin } from "@/app/(auth)/_api/auth/auth.api"; +import { TOKEN_TIMES } from "@/constants/time.constants"; +import { type ApiError } from "@/lib/api"; +import { UnauthorizedException } from "@/lib/exceptions"; +import { getSessionFromServer } from "@/lib/session"; + +/** + * 로그인 요청 + * @param req - 요청 객체 + * @returns 응답 객체 + */ +export const POST = async (req: NextRequest) => { + const { code } = await req.json(); + const origin = req.headers.get("origin"); + + if (!code) { + return NextResponse.json( + { errorMessage: "인가 코드가 필요합니다." }, + { status: 400 } + ); + } + + if (!origin) { + return NextResponse.json( + { errorMessage: "올바르지 않은 요청입니다." }, + { status: 400 } + ); + } + + try { + const data = await postLogin({ + code, + origin, + }); + const session = await getSessionFromServer(); + + session.isLoggedIn = true; + session.accessToken = data.token.accessToken; + session.refreshToken = data.token.refreshToken; + session.userId = String(data.information.id); + // TODO: 백엔드로부터 토큰 만료 시간 받아오면 변경하기 (1시간) + session.accessTokenExpiresAt = + Date.now() + TOKEN_TIMES.ACCESS_TOKEN_LIFESPAN; + + await session.save(); + + return NextResponse.json(data.information); + } catch (error) { + console.error("Login failed:", error); + + return NextResponse.json( + { errorMessage: "로그인에 실패했습니다." }, + { status: error instanceof UnauthorizedException ? 401 : 400 } + ); + } +}; diff --git a/src/app/api/auth/reissue/route.ts b/src/app/api/auth/reissue/route.ts new file mode 100644 index 00000000..c76072e8 --- /dev/null +++ b/src/app/api/auth/reissue/route.ts @@ -0,0 +1,35 @@ +import { NextResponse } from "next/server"; + +import { postReissue } from "@/app/(auth)/_api/auth/auth.api"; +import { type ApiError } from "@/lib/api"; +import { getSessionFromServer } from "@/lib/session"; + +export const POST = async () => { + const session = await getSessionFromServer(); + const { refreshToken } = session; + + if (!refreshToken) { + return NextResponse.json( + { errorMessage: "리프레시 토큰이 없습니다." }, + { status: 401 } + ); + } + + try { + const data = await postReissue({ refreshToken }); + + session.accessToken = data.accessToken; + session.refreshToken = data.refreshToken; + await session.save(); + + return NextResponse.json(session); + } catch (error) { + console.error("Reissue failed:", error); + // 토큰 재발급 실패 시 세션 초기화 + session.destroy(); + return NextResponse.json( + { errorMessage: "토큰 재발급에 실패했습니다." }, + { status: 401 } + ); + } +}; diff --git a/src/app/api/session/route.ts b/src/app/api/session/route.ts new file mode 100644 index 00000000..d0669b8a --- /dev/null +++ b/src/app/api/session/route.ts @@ -0,0 +1,8 @@ +import { NextResponse } from "next/server"; + +import { getSessionFromServer } from "@/lib/session"; + +export const GET = async () => { + const session = await getSessionFromServer(); + return NextResponse.json(session); +}; diff --git a/src/constants/index.ts b/src/constants/index.ts new file mode 100644 index 00000000..dd7848aa --- /dev/null +++ b/src/constants/index.ts @@ -0,0 +1 @@ +export * from "./time.constants"; diff --git a/src/constants/time.constants.ts b/src/constants/time.constants.ts new file mode 100644 index 00000000..45027cd6 --- /dev/null +++ b/src/constants/time.constants.ts @@ -0,0 +1,29 @@ +// 기본 시간 단위 (밀리초) +const SECOND = 1000; +const MINUTE = 60 * SECOND; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; + +export const TIME = { + SECOND, + MINUTE, + HOUR, + DAY, +}; + +/** + * 인증 및 토큰과 관련된 시간 상수(밀리초 단위)를 정의합니다. + */ +export const TOKEN_TIMES = { + // /** Access Token의 유효 시간 (1시간) */ + ACCESS_TOKEN_LIFESPAN: 1 * HOUR, + + // /** 클라이언트 세션 캐시 유지 시간 (59분) */ + CLIENT_SESSION_CACHE_DURATION: 59 * MINUTE, + + // /** 주기적 토큰 재발급 간격 (59분) */ + PERIODIC_TOKEN_REFRESH_INTERVAL: 59 * MINUTE, + + // /** 미들웨어에서 토큰 만료 임박으로 간주하고 재발급을 시도하는 시간 (5분) */ + MIDDLEWARE_TOKEN_REFRESH_THRESHOLD: 5 * MINUTE, +}; diff --git a/src/lib/api/client.ts b/src/lib/api/client.ts new file mode 100644 index 00000000..07f14695 --- /dev/null +++ b/src/lib/api/client.ts @@ -0,0 +1,130 @@ +import ky, { type BeforeRetryState, type HTTPError } from "ky"; + +import { postClientReissue } from "@/app/(auth)/_api/auth/auth.api"; +import { + ApiException, + ForbiddenException, + NetworkException, + StatusCode, + TimeoutException, + UnauthorizedException, +} from "@/lib/exceptions"; +import { + clearClientSessionCache, + getSessionFromClient, + getSessionFromServer, +} from "@/lib/session"; +import { isServer } from "@/lib/utils/environment"; + +import { type ApiError } from "./type"; + +const apiErrorHandler = async (error: HTTPError) => { + const { response } = error; + + if (!response) { + throw new NetworkException("네트워크 오류가 발생했습니다."); + } + + const { status } = response; + + let errorMessage: string; + + try { + const errorData = await response.json(); + errorMessage = errorData?.errorMessage || "알 수 없는 오류가 발생했습니다."; + } catch { + errorMessage = "서버 응답을 처리하는 중 오류가 발생했습니다."; + } + + switch (status) { + case StatusCode.Unauthorized: + throw new UnauthorizedException(errorMessage); + case StatusCode.Forbidden: + throw new ForbiddenException(errorMessage); + case StatusCode.NotFound: + throw new ApiException(errorMessage, StatusCode.NotFound); + case StatusCode.Timeout: + throw new TimeoutException(errorMessage); + default: + throw new ApiException(errorMessage, status as StatusCode); + } +}; + +const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8080"; + +export const http = ky.create({ + prefixUrl: API_BASE_URL, + hooks: { + beforeError: [apiErrorHandler], + }, +}); + +/** + * 요청 전에 토큰을 주입합니다. + * @description 서버 환경이면 서버 세션을 가져오고, 클라이언트 환경이면 클라이언트 세션을 가져옵니다. + */ +const setAuthorizationHeader = async (request: Request) => { + const session = isServer() + ? await getSessionFromServer() + : await getSessionFromClient(); + + if (session?.isLoggedIn && session.accessToken) { + request.headers.set("Authorization", `Bearer ${session.accessToken}`); + } +}; + +let isRefreshing = false; + +/** + * 토큰 재발급을 처리하는 함수 + * @description 401 에러가 발생하면 토큰을 재발급하고 재시도를 시도합니다. + */ +const refreshTokenAndRetry = async ({ error }: BeforeRetryState) => { + // HTTPError가 아니거나, 401 에러가 아니거나, 서버 환경이거나, 이미 재발급 중이면 재시도를 중단합니다. + if (!(error instanceof UnauthorizedException) || isServer() || isRefreshing) { + return; + } + + isRefreshing = true; + + try { + // 토큰 재발급 + await postClientReissue(); + + // 재발급 성공 후, 클라이언트의 세션 캐시를 비워 새 정보를 가져오게 합니다. + clearClientSessionCache(); + + // 재발급 성공 + console.info("토큰 재발급 성공, 원래 요청을 재시도합니다."); + } catch (refreshError) { + console.error("토큰 재발급 실패:", refreshError); + // 재발급 실패 시, 재시도를 완전히 중단하고 로그인 페이지로 보냄 + clearClientSessionCache(); + // window.location.href = '/login'; + + // ky.stop을 던지면 재시도를 멈추고 원래 에러를 throw 합니다. + throw ky.stop; + } finally { + isRefreshing = false; + } +}; + +export const authHttp = http.extend({ + hooks: { + // 요청 전에 토큰을 주입합니다. + beforeRequest: [setAuthorizationHeader], + // beforeRetry 훅을 사용하여 재시도 전에 토큰을 갱신합니다. + beforeRetry: [refreshTokenAndRetry], + }, + // 기본적으로 4xx, 5xx 에러 시 재시도를 하지 않지만, + // 우리가 직접 제어하기 위해 retry 옵션을 설정합니다. + retry: { + limit: 1, // 401 에러 시 딱 한 번만 재시도합니다. + methods: ["get", "post", "put", "delete", "patch"], // 모든 HTTP 메서드에 대해 + statusCodes: [StatusCode.Unauthorized], // 401 상태 코드에 대해서만 + }, +}); + +export const nextHttp = http.extend({ + prefixUrl: "/", +}); diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts new file mode 100644 index 00000000..d2fc6614 --- /dev/null +++ b/src/lib/api/index.ts @@ -0,0 +1,2 @@ +export * from "./client"; +export * from "./type"; diff --git a/src/lib/api/type.ts b/src/lib/api/type.ts new file mode 100644 index 00000000..a1fd83eb --- /dev/null +++ b/src/lib/api/type.ts @@ -0,0 +1,22 @@ +/** + * 에러 응답 타입 + * @description 에러 응답 타입은 에러 코드와 에러 메시지를 포함합니다. + */ +export type ApiError = { + /** + * 에러 코드 + * @example "AUTH002" + */ + errorCode?: string; + /** + * 에러 메시지 + * @example "이미 만료된 토큰입니다." + */ + errorMessage: string; +}; + +/** + * 응답 타입 + * @description 성공 시 데이터를 반환하고, 실패 시 에러 응답을 반환합니다. + */ +export type ApiResponse = T | ApiError; diff --git a/src/lib/exceptions/exceptions.ts b/src/lib/exceptions/exceptions.ts new file mode 100644 index 00000000..681acf5d --- /dev/null +++ b/src/lib/exceptions/exceptions.ts @@ -0,0 +1,116 @@ +/** + * 애플리케이션에서 사용되는 주요 HTTP 상태 코드를 정의합니다. + */ +export enum StatusCode { + /** 401: 인증되지 않았거나 유효하지 않은 인증 정보 */ + Unauthorized = 401, + /** 403: 접근 권한이 없음 */ + Forbidden = 403, + /** 404: 요청한 리소스를 찾을 수 없음 */ + NotFound = 404, + /** 408: 요청 시간 초과 */ + Timeout = 408, + /** 500: 서버 내부 오류 */ + InternalServerError = 500, + /** 502: 게이트웨이 오류 */ + BadGateway = 502, +} + +/** + * 모든 커스텀 예외 클래스의 기반이 되는 추상 클래스입니다. + * `Error`를 상속받아 공통 속성을 정의합니다. + */ +class BaseException extends Error { + /** 예외 발생 시각 (ISO 8601 형식) */ + timestamp: string; + /** 예외에 해당하는 HTTP 상태 코드 */ + statusCode: StatusCode; + /** 백엔드에서 정의한 특정 에러 코드 (선택 사항) */ + errorCode?: string; + + constructor(message: string, statusCode: StatusCode, errorCode?: string) { + super(message); + this.name = this.constructor.name; + this.timestamp = new Date().toISOString(); + this.statusCode = statusCode; + this.errorCode = errorCode; + } +} + +/** + * 일반적인 커스텀 예외 클래스입니다. + */ +export class CustomException extends BaseException { + constructor( + message: string, + statusCode: StatusCode = StatusCode.InternalServerError, + errorCode?: string + ) { + super(message, statusCode, errorCode); + } +} + +/** + * 네트워크 연결 문제로 인해 발생하는 예외입니다. (예: 오프라인 상태) + */ +export class NetworkException extends CustomException { + constructor( + message: string, + statusCode: StatusCode = StatusCode.InternalServerError, + errorCode?: string + ) { + super(message, statusCode, errorCode); + } +} + +/** + * 백엔드 API 응답 관련 예외입니다. (예: 404 Not Found) + */ +export class ApiException extends CustomException { + constructor( + message: string, + statusCode: StatusCode = StatusCode.NotFound, + errorCode?: string + ) { + super(message, statusCode, errorCode); + } +} + +/** + * 요청 시간 초과로 인해 발생하는 예외입니다. + */ +export class TimeoutException extends CustomException { + constructor( + message: string, + statusCode: StatusCode = StatusCode.Timeout, + errorCode?: string + ) { + super(message, statusCode, errorCode); + } +} + +/** + * 인증되지 않은 사용자의 요청으로 발생하는 예외입니다. (401) + */ +export class UnauthorizedException extends CustomException { + constructor( + message: string, + statusCode: StatusCode = StatusCode.Unauthorized, + errorCode?: string + ) { + super(message, statusCode, errorCode); + } +} + +/** + * 권한이 없는 리소스에 접근 시 발생하는 예외입니다. (403) + */ +export class ForbiddenException extends CustomException { + constructor( + message: string, + statusCode: StatusCode = StatusCode.Forbidden, + errorCode?: string + ) { + super(message, statusCode, errorCode); + } +} diff --git a/src/lib/exceptions/index.ts b/src/lib/exceptions/index.ts new file mode 100644 index 00000000..dce61d60 --- /dev/null +++ b/src/lib/exceptions/index.ts @@ -0,0 +1 @@ +export * from "./exceptions"; diff --git a/src/lib/session/clientSession.ts b/src/lib/session/clientSession.ts new file mode 100644 index 00000000..31171034 --- /dev/null +++ b/src/lib/session/clientSession.ts @@ -0,0 +1,39 @@ +import { getSession } from "@/app/(auth)/_api/session/session.api"; +import { TOKEN_TIMES } from "@/constants"; +import { type SessionData } from "@/lib/session"; + +const clientSessionCache = new Map< + string, + { session: SessionData; expiresAt: number } +>(); + +const CLIENT_SESSION_CACHE_KEY = "session"; +const CACHE_DURATION_MS = TOKEN_TIMES.CLIENT_SESSION_CACHE_DURATION; // 59분 + +/** + * 클라이언트 세션 캐시를 비웁니다. + */ +export const clearClientSessionCache = () => { + clientSessionCache.clear(); +}; + +/** + * 클라이언트 세션 캐시를 가져옵니다. + * @description 캐시가 있고, 만료되지 않았다면 캐시된 세션을 반환합니다. + */ +export const getSessionFromClient = async () => { + const now = Date.now(); + + // 캐시가 있고, 만료되지 않았다면 반환 + const cached = clientSessionCache.get(CLIENT_SESSION_CACHE_KEY); + if (cached && cached.expiresAt > now) { + return cached.session; + } + + // API로 세션 요청 + const session = await getSession(); + const expiresAt = now + CACHE_DURATION_MS; + clientSessionCache.set(CLIENT_SESSION_CACHE_KEY, { session, expiresAt }); + + return session; +}; diff --git a/src/lib/session/index.ts b/src/lib/session/index.ts new file mode 100644 index 00000000..d37df41a --- /dev/null +++ b/src/lib/session/index.ts @@ -0,0 +1,4 @@ +export { clearClientSessionCache, getSessionFromClient } from "./clientSession"; +export { getSessionFromServer } from "./serverSession"; +export { defaultSession, getSession, sessionOptions } from "./session"; +export { type SessionData } from "./type"; diff --git a/src/lib/session/serverSession.ts b/src/lib/session/serverSession.ts new file mode 100644 index 00000000..cbf1ed62 --- /dev/null +++ b/src/lib/session/serverSession.ts @@ -0,0 +1,4 @@ +export const getSessionFromServer = async () => { + const { getSession } = await import("./session"); + return await getSession(); +}; diff --git a/src/lib/session/session.ts b/src/lib/session/session.ts new file mode 100644 index 00000000..cbc159ac --- /dev/null +++ b/src/lib/session/session.ts @@ -0,0 +1,36 @@ +import { getIronSession, type SessionOptions } from "iron-session"; +import { cache } from "react"; + +import { type SessionData } from "./type"; + +export const defaultSession: SessionData = { + isLoggedIn: false, +}; + +const COOKIE_NAME = "time-eat-session"; +const COOKIE_MAX_AGE = 60 * 60 * 24 * 14; // 14일 + +export const sessionOptions: SessionOptions = { + password: process.env.SESSION_PASSWORD || "", + cookieName: COOKIE_NAME, + cookieOptions: { + secure: process.env.NODE_ENV === "production", + httpOnly: true, + maxAge: COOKIE_MAX_AGE, + }, +}; + +/** + * 세션 데이터를 가져옵니다. + * + * @description react cache를 사용하여 1회 렌더링 사이클 내에서 요청이 캐싱되도록 합니다. + */ +export const getSession = cache(async () => { + const { cookies } = await import("next/headers"); + const session = await getIronSession( + await cookies(), + sessionOptions + ); + + return session; +}); diff --git a/src/lib/session/type.ts b/src/lib/session/type.ts new file mode 100644 index 00000000..02b48d90 --- /dev/null +++ b/src/lib/session/type.ts @@ -0,0 +1,7 @@ +export type SessionData = { + accessToken?: string; + refreshToken?: string; + userId?: string; + isLoggedIn?: boolean; + accessTokenExpiresAt?: number; +}; diff --git a/src/lib/utils/environment.ts b/src/lib/utils/environment.ts new file mode 100644 index 00000000..a0aac0a1 --- /dev/null +++ b/src/lib/utils/environment.ts @@ -0,0 +1,4 @@ +export const isClient = () => + typeof window !== "undefined" && typeof document !== "undefined"; + +export const isServer = () => !isClient(); diff --git a/src/middleware.ts b/src/middleware.ts new file mode 100644 index 00000000..9b804ad8 --- /dev/null +++ b/src/middleware.ts @@ -0,0 +1,73 @@ +// src/middleware.ts +import { type NextRequest, NextResponse } from "next/server"; + +import { postReissue } from "@/app/(auth)/_api/auth/auth.api"; +import { TOKEN_TIMES } from "@/constants"; +import { getSessionFromServer } from "@/lib/session"; + +// 인증이 필요 없는 경로 +const PUBLIC_PATHS = ["/", "/login", "/signup", "/public", "/login/callback"]; + +const isPublicPath = (pathname: string) => { + return PUBLIC_PATHS.some(path => pathname.startsWith(path)); +}; + +export async function middleware(request: NextRequest) { + const { pathname } = request.nextUrl; + + if (isPublicPath(pathname)) { + return NextResponse.next(); + } + + const session = await getSessionFromServer(); + + if (!session.isLoggedIn) { + const loginUrl = new URL("/login", request.url); + + return NextResponse.redirect(loginUrl); + } + + const now = Date.now(); + + // Access Token이 만료되었거나, 5분 이내에 만료될 예정인 경우 + if ( + session.accessTokenExpiresAt && + session.accessTokenExpiresAt < + now + TOKEN_TIMES.MIDDLEWARE_TOKEN_REFRESH_THRESHOLD + ) { + try { + if (!session.refreshToken) { + throw new Error("No refresh token"); + } + + // 백엔드에 직접 토큰 재발급 요청 + const newTokens = await postReissue({ + refreshToken: session.refreshToken, + }); + + // 세션에 새로운 토큰 정보와 만료 시각 갱신 + session.accessToken = newTokens.accessToken; + session.refreshToken = newTokens.refreshToken; + session.accessTokenExpiresAt = + Date.now() + TOKEN_TIMES.ACCESS_TOKEN_LIFESPAN; + + await session.save(); + console.info("토큰이 성공적으로 갱신되었습니다."); + } catch (error) { + console.error("토큰 갱신 실패:", error); + // 재발급 실패 시 세션을 파기하고 로그인 페이지로 리디렉트 + session.destroy(); + const loginUrl = new URL("/login", request.url); + loginUrl.searchParams.set("error", "session_expired"); + return NextResponse.redirect(loginUrl); + } + } + + return NextResponse.next(); +} + +export const config = { + matcher: [ + "/((?!api|_next/static|_next/image|favicon.ico|mockServiceWorker.js|pwaServiceWorker.js|.*\\.png$|manifest.webmanifest).*)", + ], +};