diff --git a/server/package.json b/server/package.json index e2db478..c31dbee 100644 --- a/server/package.json +++ b/server/package.json @@ -19,7 +19,9 @@ "devDependencies": { "@biomejs/biome": "2.2.0", "@tsconfig/strictest": "^2.0.5", + "@types/cookie-parser": "^1.4.9", "@types/express": "^5.0.3", + "@types/express-session": "^1.18.0", "@types/jest": "^30.0.0", "@types/node": "^24.3.0", "@types/pg": "^8.15.5", @@ -29,7 +31,11 @@ "typescript": "^5.9.2" }, "dependencies": { + "arctic": "^3.7.0", + "axios": "^1.7.9", + "cookie-parser": "^1.4.7", "express": "^5.1.0", + "express-session": "^1.18.0", "openapi-backend": "^5.15.0", "pg": "^8.16.3", "postgres-migrations": "^5.3.0" diff --git a/server/pnpm-lock.yaml b/server/pnpm-lock.yaml index 8360b74..609690f 100644 --- a/server/pnpm-lock.yaml +++ b/server/pnpm-lock.yaml @@ -8,9 +8,21 @@ importers: .: dependencies: + arctic: + specifier: ^3.7.0 + version: 3.7.0 + axios: + specifier: ^1.7.9 + version: 1.11.0 + cookie-parser: + specifier: ^1.4.7 + version: 1.4.7 express: specifier: ^5.1.0 version: 5.1.0 + express-session: + specifier: ^1.18.0 + version: 1.18.2 openapi-backend: specifier: ^5.15.0 version: 5.15.0 @@ -27,9 +39,15 @@ importers: '@tsconfig/strictest': specifier: ^2.0.5 version: 2.0.5 + '@types/cookie-parser': + specifier: ^1.4.9 + version: 1.4.9(@types/express@5.0.3) '@types/express': specifier: ^5.0.3 version: 5.0.3 + '@types/express-session': + specifier: ^1.18.0 + version: 1.18.2 '@types/jest': specifier: ^30.0.0 version: 30.0.0 @@ -558,6 +576,24 @@ packages: '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} + '@oslojs/asn1@1.0.0': + resolution: {integrity: sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA==} + + '@oslojs/binary@1.0.0': + resolution: {integrity: sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ==} + + '@oslojs/crypto@1.0.1': + resolution: {integrity: sha512-7n08G8nWjAr/Yu3vu9zzrd0L9XnrJfpMioQcvCMxBIiF5orECHe5/3J0jmXRVvgfqMm/+4oxlQ+Sq39COYLcNQ==} + + '@oslojs/encoding@0.4.1': + resolution: {integrity: sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q==} + + '@oslojs/encoding@1.1.0': + resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} + + '@oslojs/jwt@0.2.0': + resolution: {integrity: sha512-bLE7BtHrURedCn4Mco3ma9L4Y1GR2SMBuIvjWr7rmQ4/W/4Jy70TIAgZ+0nIlk0xHz1vNP8x8DCns45Sb2XRbg==} + '@pkgjs/parseargs@0.11.0': resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} @@ -599,9 +635,17 @@ packages: '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/cookie-parser@1.4.9': + resolution: {integrity: sha512-tGZiZ2Gtc4m3wIdLkZ8mkj1T6CEHb35+VApbL2T14Dew8HA7c+04dmKqsKRNC+8RJPm16JEK0tFSwdZqubfc4g==} + peerDependencies: + '@types/express': '*' + '@types/express-serve-static-core@5.0.7': resolution: {integrity: sha512-R+33OsgWw7rOhD1emjU7dzCDHucJrgJXMA5PYCzJxVil0dsyx5iBEPHqpPfiKNJQb7lZ1vxwoLR4Z87bBUpeGQ==} + '@types/express-session@1.18.2': + resolution: {integrity: sha512-k+I0BxwVXsnEU2hV77cCobC08kIsn4y44C3gC0b46uxZVMaXA04lSPgRLR/bSL2w0t0ShJiG8o4jPzRG/nscFg==} + '@types/express@5.0.3': resolution: {integrity: sha512-wGA0NX93b19/dZC1J18tKWVIYWyyF2ZjT9vin/NRu0qzzvfVzWjs04iq2rQ3H65vCTQYlRqs3YHfY7zjdV+9Kw==} @@ -794,12 +838,21 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} + arctic@3.7.0: + resolution: {integrity: sha512-ZMQ+f6VazDgUJOd+qNV+H7GohNSYal1mVjm5kEaZfE2Ifb7Ss70w+Q7xpJC87qZDkMZIXYf0pTIYZA0OPasSbw==} + argparse@1.0.10: resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + + axios@1.11.0: + resolution: {integrity: sha512-1Lx3WLFQWm3ooKDYZD1eXmoGO9fxYQjrycfHFC8P0sCfQVXyROp0p9PFWBehewBOdCwHc+f/b8I0fMto5eSfwA==} + babel-jest@30.0.5: resolution: {integrity: sha512-mRijnKimhGDMsizTvBTWotwNpzrkHr+VvZUQBof2AufXKB8NXrL1W69TG20EvOz7aevx6FTJIaBuBkYxS8zolg==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} @@ -920,6 +973,10 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} @@ -934,6 +991,16 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-parser@1.4.7: + resolution: {integrity: sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==} + engines: {node: '>= 0.8.0'} + + cookie-signature@1.0.6: + resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} + + cookie-signature@1.0.7: + resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==} + cookie-signature@1.2.2: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} @@ -950,6 +1017,14 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + debug@4.4.1: resolution: {integrity: sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==} engines: {node: '>=6.0'} @@ -971,6 +1046,10 @@ packages: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + depd@2.0.0: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} @@ -1024,6 +1103,10 @@ packages: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + esbuild@0.25.9: resolution: {integrity: sha512-CRbODhYyQx3qp7ZEwzxOk4JBqmD/seJrzPa/cGjY1VtIn5E09Oi9/dB4JwctnfZ8Q8iT7rioVv5k/FNT/uf54g==} engines: {node: '>=18'} @@ -1061,6 +1144,10 @@ packages: resolution: {integrity: sha512-P0te2pt+hHI5qLJkIR+iMvS+lYUZml8rKKsohVHAGY+uClp9XVbdyYNJOIjSRpHVp8s8YqxJCiHUkSYZGr8rtQ==} engines: {node: ^18.14.0 || ^20.0.0 || ^22.0.0 || >=24.0.0} + express-session@1.18.2: + resolution: {integrity: sha512-SZjssGQC7TzTs9rpPDuUrR23GNZ9+2+IkA/+IJWmvQilTr5OSliEHGF+D9scbIpdC6yGtTI0/VhaHoVes2AN/A==} + engines: {node: '>= 0.8.0'} + express@5.1.0: resolution: {integrity: sha512-DT9ck5YIRU+8GYzzU5kT3eHGA5iL+1Zd0EutOmTE9Dtk+Tvuzd23VBU+ec7HPNSTxXYO55gPV/hq4pSBJDjFpA==} engines: {node: '>= 18'} @@ -1089,10 +1176,23 @@ packages: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} + follow-redirects@1.15.11: + resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} + engines: {node: '>=4.0'} + peerDependencies: + debug: '*' + peerDependenciesMeta: + debug: + optional: true + foreground-child@3.3.1: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + form-data@4.0.4: + resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} + engines: {node: '>= 6'} + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -1167,6 +1267,10 @@ packages: resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} engines: {node: '>= 0.4'} + has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} @@ -1464,10 +1568,18 @@ packages: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + mime-db@1.54.0: resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} engines: {node: '>= 0.6'} + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + mime-types@3.0.1: resolution: {integrity: sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA==} engines: {node: '>= 0.6'} @@ -1493,6 +1605,9 @@ packages: mock-json-schema@1.1.1: resolution: {integrity: sha512-YV23vlsLP1EEOy0EviUvZTluXjLR+rhMzeayP2rcDiezj3RW01MhOSQkbQskdtg0K2fnGas5LKbSXgNjAOSX4A==} + ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -1533,6 +1648,10 @@ packages: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} + on-headers@1.1.0: + resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==} + engines: {node: '>= 0.8'} + once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -1679,6 +1798,9 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + proxy-from-env@1.1.0: + resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} + pure-rand@7.0.1: resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==} @@ -1686,6 +1808,10 @@ packages: resolution: {integrity: sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==} engines: {node: '>=0.6'} + random-bytes@1.0.0: + resolution: {integrity: sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==} + engines: {node: '>= 0.8'} + range-parser@1.2.1: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} @@ -1931,6 +2057,10 @@ packages: engines: {node: '>=0.8.0'} hasBin: true + uid-safe@2.1.5: + resolution: {integrity: sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==} + engines: {node: '>= 0.8'} + undici-types@7.10.0: resolution: {integrity: sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==} @@ -2556,6 +2686,25 @@ snapshots: '@tybys/wasm-util': 0.10.0 optional: true + '@oslojs/asn1@1.0.0': + dependencies: + '@oslojs/binary': 1.0.0 + + '@oslojs/binary@1.0.0': {} + + '@oslojs/crypto@1.0.1': + dependencies: + '@oslojs/asn1': 1.0.0 + '@oslojs/binary': 1.0.0 + + '@oslojs/encoding@0.4.1': {} + + '@oslojs/encoding@1.1.0': {} + + '@oslojs/jwt@0.2.0': + dependencies: + '@oslojs/encoding': 0.4.1 + '@pkgjs/parseargs@0.11.0': optional: true @@ -2608,6 +2757,10 @@ snapshots: dependencies: '@types/node': 24.3.0 + '@types/cookie-parser@1.4.9(@types/express@5.0.3)': + dependencies: + '@types/express': 5.0.3 + '@types/express-serve-static-core@5.0.7': dependencies: '@types/node': 24.3.0 @@ -2615,6 +2768,10 @@ snapshots: '@types/range-parser': 1.2.7 '@types/send': 0.17.5 + '@types/express-session@1.18.2': + dependencies: + '@types/express': 5.0.3 + '@types/express@5.0.3': dependencies: '@types/body-parser': 1.19.6 @@ -2773,12 +2930,28 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.1 + arctic@3.7.0: + dependencies: + '@oslojs/crypto': 1.0.1 + '@oslojs/encoding': 1.1.0 + '@oslojs/jwt': 0.2.0 + argparse@1.0.10: dependencies: sprintf-js: 1.0.3 argparse@2.0.1: {} + asynckit@0.4.0: {} + + axios@1.11.0: + dependencies: + follow-redirects: 1.15.11 + form-data: 4.0.4 + proxy-from-env: 1.1.0 + transitivePeerDependencies: + - debug + babel-jest@30.0.5(@babel/core@7.28.3): dependencies: '@babel/core': 7.28.3 @@ -2928,6 +3101,10 @@ snapshots: color-name@1.1.4: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + concat-map@0.0.1: {} content-disposition@1.0.0: @@ -2938,6 +3115,15 @@ snapshots: convert-source-map@2.0.0: {} + cookie-parser@1.4.7: + dependencies: + cookie: 0.7.2 + cookie-signature: 1.0.6 + + cookie-signature@1.0.6: {} + + cookie-signature@1.0.7: {} + cookie-signature@1.2.2: {} cookie@0.7.2: {} @@ -2950,6 +3136,10 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + debug@2.6.9: + dependencies: + ms: 2.0.0 + debug@4.4.1: dependencies: ms: 2.1.3 @@ -2958,6 +3148,8 @@ snapshots: deepmerge@4.3.1: {} + delayed-stream@1.0.0: {} + depd@2.0.0: {} dereference-json-schema@0.2.1: {} @@ -2996,6 +3188,13 @@ snapshots: dependencies: es-errors: 1.3.0 + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + esbuild@0.25.9: optionalDependencies: '@esbuild/aix-ppc64': 0.25.9 @@ -3058,6 +3257,19 @@ snapshots: jest-mock: 30.0.5 jest-util: 30.0.5 + express-session@1.18.2: + dependencies: + cookie: 0.7.2 + cookie-signature: 1.0.7 + debug: 2.6.9 + depd: 2.0.0 + on-headers: 1.1.0 + parseurl: 1.3.3 + safe-buffer: 5.2.1 + uid-safe: 2.1.5 + transitivePeerDependencies: + - supports-color + express@5.1.0: dependencies: accepts: 2.0.0 @@ -3120,11 +3332,21 @@ snapshots: locate-path: 5.0.0 path-exists: 4.0.0 + follow-redirects@1.15.11: {} + foreground-child@3.3.1: dependencies: cross-spawn: 7.0.6 signal-exit: 4.1.0 + form-data@4.0.4: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.2 + mime-types: 2.1.35 + forwarded@0.2.0: {} fresh@2.0.0: {} @@ -3201,6 +3423,10 @@ snapshots: has-symbols@1.1.0: {} + has-tostringtag@1.0.2: + dependencies: + has-symbols: 1.1.0 + hasown@2.0.2: dependencies: function-bind: 1.1.2 @@ -3661,8 +3887,14 @@ snapshots: braces: 3.0.3 picomatch: 2.3.1 + mime-db@1.52.0: {} + mime-db@1.54.0: {} + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + mime-types@3.0.1: dependencies: mime-db: 1.54.0 @@ -3685,6 +3917,8 @@ snapshots: dependencies: lodash: 4.17.21 + ms@2.0.0: {} + ms@2.1.3: {} napi-postinstall@0.3.3: {} @@ -3711,6 +3945,8 @@ snapshots: dependencies: ee-first: 1.1.1 + on-headers@1.1.0: {} + once@1.4.0: dependencies: wrappy: 1.0.2 @@ -3854,12 +4090,16 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + proxy-from-env@1.1.0: {} + pure-rand@7.0.1: {} qs@6.14.0: dependencies: side-channel: 1.1.0 + random-bytes@1.0.0: {} + range-parser@1.2.1: {} raw-body@3.0.0: @@ -4093,6 +4333,10 @@ snapshots: uglify-js@3.19.3: optional: true + uid-safe@2.1.5: + dependencies: + random-bytes: 1.0.0 + undici-types@7.10.0: {} unpipe@1.0.0: {} diff --git a/server/src/config/env.ts b/server/src/config/env.ts index 65a5209..763485d 100644 --- a/server/src/config/env.ts +++ b/server/src/config/env.ts @@ -8,6 +8,14 @@ interface ProcessEnv { DB_HOST?: string; DB_NAME: string; DB_PORT?: string; + + GITEA_URL?: string; + GITEA_CLIENT_ID?: string; + GITEA_CLIENT_SECRET?: string; + GITEA_REDIRECT_URI?: string; + + FORGE_TYPES?: string; // comma-separated list of enabled forges + SESSION_SECRET: string; } const env = process.env as unknown as ProcessEnv; @@ -28,6 +36,20 @@ export const config = { DB_USER: requiredEnv("DB_USER"), DB_PASSWORD: requiredEnv("DB_PASSWORD"), DB_PORT: env.DB_PORT ? Number(env.DB_PORT) : 5432, + + GITEA_URL: env.GITEA_URL ?? "http://localhost:3001", + GITEA_CLIENT_ID: env.GITEA_CLIENT_ID, + GITEA_CLIENT_SECRET: env.GITEA_CLIENT_SECRET, + GITEA_REDIRECT_URI: + env.GITEA_REDIRECT_URI ?? "http://localhost:3000/auth/callback", + + FORGE_TYPES: (env.FORGE_TYPES ?? "gitea") + .split(",") + .map((f) => f.trim().toLowerCase()), + + SESSION_SECRET: requiredEnv("SESSION_SECRET"), + + IS_PRODUCTION: env.NODE_ENV === "production", }; export const isProduction = config.NODE_ENV === "production"; diff --git a/server/src/routes/auth.ts b/server/src/routes/auth.ts new file mode 100644 index 0000000..67f12c9 --- /dev/null +++ b/server/src/routes/auth.ts @@ -0,0 +1,90 @@ +import { Router, type Request, type Response } from "express"; +import { createForge, listAvailableForges } from "../services/forges"; +import { config } from "../config/env"; + +const authRouter = Router(); + +authRouter.get("/providers", (_req, res) => { + res.json({ providers: listAvailableForges() }); +}); + +authRouter.get("/login/:forgeType", (req: Request, res: Response) => { + const forgeType = req.params["forgeType"]; + if (!forgeType) return res.status(400).json({ error: "Missing forgeType" }); + + try { + const forge = createForge(forgeType); + const authData = forge.getAuthorizationUrl(); + + req.session.codeVerifier = authData.codeVerifier; + req.session.forgeType = forgeType; + + res.cookie("state", authData.state, { + secure: config.IS_PRODUCTION, + path: "/", + httpOnly: true, + maxAge: 10 * 60 * 1000, // 10 minutes + }); + + res.json({ authUrl: authData.url }); + } catch (err) { + console.error("Failed to generate auth URL:", err); + res.status(400).json({ error: (err as Error).message }); + } +}); + +authRouter.get("/callback", async (req: Request, res: Response) => { + const forgeType = req.session.forgeType; + const codeVerifier = req.session.codeVerifier; + const state = req.query["state"] as string; + const code = req.query["code"] as string; + + if (!forgeType || !state || !code || !codeVerifier) { + return res.status(400).json({ error: "Missing OAuth callback parameters" }); + } + + try { + const forge = createForge(forgeType); + + const { accessToken } = await forge.exchangeCodeForToken( + code, + codeVerifier, + ); + + const userInfo = await forge.getUserInfo(accessToken); + + // ----------------------------- + // TODO: Insert user in DB + // ----------------------------- + const simulatedUserId = 1; // replace with actual DB user_id + + req.session.userId = simulatedUserId; + req.session.forgeType = forgeType; + + // Clear OAuth session data + delete req.session.codeVerifier; + + res.json({ + success: true, + user: { + id: simulatedUserId, // internal molci ID + forgeUserId: userInfo.id, // forge user ID + login: userInfo.login, + avatar_url: userInfo.avatar_url, + }, + }); + } catch (err) { + console.error("OAuth callback error:", err); + res.status(500).json({ error: "OAuth callback failed" }); + } +}); + +authRouter.post("/logout", (req: Request, res: Response) => { + req.session.destroy((err) => { + if (err) return res.status(500).json({ error: "Failed to logout" }); + res.clearCookie("connect.sid"); + res.json({ success: true }); + }); +}); + +export default authRouter; diff --git a/server/src/server.ts b/server/src/server.ts index 81439e7..4a53950 100644 --- a/server/src/server.ts +++ b/server/src/server.ts @@ -1,8 +1,35 @@ -import express from "express"; -import { connectDB, pool } from "./config/db"; +import express, { + type Request as ExpressRequest, + type Response as ExpressResponse, +} from "express"; +import cookieParser from "cookie-parser"; +import session from "express-session"; import { config, isProduction } from "./config/env"; +import { connectDB } from "./config/db"; import { OpenAPIBackend, type Request } from "openapi-backend"; -import type { Repository } from "./types/openapi"; +import { createForge } from "./services/forges"; +import authRouter from "./routes/auth"; + +const app = express(); +app.use(express.json()); +app.use(cookieParser()); + +// express session for Session cookie on the browser and Session object on the server +app.use( + session({ + secret: config.SESSION_SECRET, + resave: false, + saveUninitialized: false, + cookie: { + secure: config.IS_PRODUCTION, + httpOnly: true, + maxAge: 24 * 60 * 60 * 1000, // 1 day + }, + }), +); + +app.use("/auth", authRouter); + const api = new OpenAPIBackend({ definition: "./openapi.yaml", @@ -10,37 +37,35 @@ const api = new OpenAPIBackend({ }); api.register({ - listAvailableRepos: async ( - _c, - _req: express.Request, - res: express.Response, - ) => { - const repos: Repository[] = []; - return res.json(repos); + listAvailableRepos: async (_c, req: ExpressRequest, res: ExpressResponse) => { + try { + const userId = req.session?.userId; + const forgeType = req.session?.forgeType; + + if (!userId || !forgeType) { + return res.status(401).json({ error: "Not authenticated" }); + } + + // TODO: Fetch user from DB here + const simulatedAccessToken = "FAKE_TOKEN"; // remove once DB is used + + const forge = createForge(forgeType); + const repos = await forge.listRepositories(simulatedAccessToken); + + return res.json(repos); + } catch (err) { + console.error("Failed to fetch repos:", err); + return res.status(500).json({ error: "Failed to list repositories" }); + } }, }); -const app = express(); -app.use(express.json()); - -app.get("/ping", async (_req, res) => { - try { - const result = await pool.query("SELECT NOW()"); - res.json({ success: true, time: result.rows[0].now }); - } catch (error) { - console.error("Database error:", error); - res - .status(500) - .json({ success: false, error: "Database connection failed" }); - } -}); api.init(); app.use((req, res) => api.handleRequest(req as Request, req, res)); connectDB(); - const PORT = config.APP_PORT; app.listen(PORT, () => { console.log( diff --git a/server/src/services/forges/gitea.ts b/server/src/services/forges/gitea.ts new file mode 100644 index 0000000..a9bb239 --- /dev/null +++ b/server/src/services/forges/gitea.ts @@ -0,0 +1,65 @@ +import * as arctic from "arctic"; +import { config } from "../../config/env"; +import type { Forge } from "../../types/forge"; +import type { Repository } from "../../types/openapi"; +import type { User } from "../../types/user"; + +export class GiteaForge implements Forge { + private gitea: arctic.Gitea; + + constructor() { + if (!config.GITEA_CLIENT_ID || !config.GITEA_CLIENT_SECRET) { + throw new Error("Gitea OAuth2 credentials not configured"); + } + + this.gitea = new arctic.Gitea( + config.GITEA_URL, + config.GITEA_CLIENT_ID, + config.GITEA_CLIENT_SECRET, + config.GITEA_REDIRECT_URI, + ); + } + + getAuthorizationUrl() { + const state = arctic.generateState(); + const codeVerifier = arctic.generateCodeVerifier(); + const scopes = ["read:user"]; + const url = this.gitea.createAuthorizationURL(state, codeVerifier, scopes); + + return { url: url.toString(), state, codeVerifier }; + } + + async exchangeCodeForToken(code: string, codeVerifier: string) { + const tokens = await this.gitea.validateAuthorizationCode( + code, + codeVerifier, + ); + return { accessToken: tokens.accessToken() }; + } + + async getUserInfo(accessToken: string): Promise { + const res = await fetch(`${config.GITEA_URL}/api/v1/user`, { + headers: { Authorization: `token ${accessToken}` }, + }); + if (!res.ok) throw new Error(`Failed to fetch user: ${res.status}`); + const user = await res.json(); + return { id: user.id, login: user.login, avatar_url: user.avatar_url }; + } + + async listRepositories(accessToken: string): Promise { + const res = await fetch(`${config.GITEA_URL}/api/v1/user/repos`, { + headers: { Authorization: `token ${accessToken}` }, + }); + if (!res.ok) throw new Error(`Failed to fetch repos: ${res.status}`); + return res.json(); + } + + async validateToken(accessToken: string) { + try { + await this.getUserInfo(accessToken); + return true; + } catch { + return false; + } + } +} diff --git a/server/src/services/forges/index.ts b/server/src/services/forges/index.ts new file mode 100644 index 0000000..b4cc158 --- /dev/null +++ b/server/src/services/forges/index.ts @@ -0,0 +1,31 @@ +import { GiteaForge } from "./gitea"; +import type { Forge } from "../../types/forge"; +import { config } from "../../config/env"; + +/** + * Returns a Forge implementation for the given forgeType. + * Throws an error if the forge is not enabled or not implemented. + */ +export function createForge(forgeType: string): Forge { + if (!config.FORGE_TYPES.includes(forgeType)) { + throw new Error(`Unsupported forge type: ${forgeType}`); + } + + switch (forgeType) { + case "gitea": + return new GiteaForge(); + case "github": + throw new Error("GitHubForge not implemented yet"); + case "gitlab": + throw new Error("GitLabForge not implemented yet"); + default: + throw new Error(`Unsupported forge type: ${forgeType}`); + } +} + +/** + * Returns the list of available forge types from config. + */ +export function listAvailableForges(): string[] { + return [...config.FORGE_TYPES]; +} diff --git a/server/src/services/oauth.ts b/server/src/services/oauth.ts new file mode 100644 index 0000000..c3c93eb --- /dev/null +++ b/server/src/services/oauth.ts @@ -0,0 +1,88 @@ +import * as arctic from "arctic"; +import { config } from "../config/env"; +import type { User } from "../types/user"; + + +export class OAuthService { + private gitea: arctic.Gitea; + + constructor() { + if (!config.GITEA_CLIENT_ID || !config.GITEA_CLIENT_SECRET) { + throw new Error("Gitea OAuth2 credentials not configured"); + } + + this.gitea = new arctic.Gitea( + config.GITEA_URL, + config.GITEA_CLIENT_ID, + config.GITEA_CLIENT_SECRET, + config.GITEA_REDIRECT_URI, + ); + } + + getAuthorizationUrl(): { url: string; state: string; codeVerifier: string } { + const state = arctic.generateState(); + const codeVerifier = arctic.generateCodeVerifier(); + const scopes = ["read:user"]; + const url = this.gitea.createAuthorizationURL(state, codeVerifier, scopes); + + return { + url: url.toString(), + state, + codeVerifier, + }; + } + + /** + * Exchange authorization code for access token + */ + // https://arcticjs.dev/guides/oauth2-pkce + // https://arcticjs.dev/providers/gitea + async exchangeCodeForToken( + code: string, + codeVerifier: string, + ): Promise<{ accessToken: string }> { + try { + const tokens = await this.gitea.validateAuthorizationCode( + code, + codeVerifier, + ); + return { accessToken: tokens.accessToken() }; + } catch (error) { + console.error("Failed to exchange code for token:", error); + throw new Error("Failed to exchange authorisation code for token"); + } + } + + async getUserInfo(accessToken: string): Promise { + try { + const response = await fetch(`${config.GITEA_URL}/api/v1/user`, { + headers: { + Authorization: `token ${accessToken}`, + }, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch user info: ${response.status}`); + } + + const user = await response.json(); + return { + id: user.id, + login: user.login, + avatar_url: user.avatar_url, + }; + } catch (error) { + console.error("Failed to get user info:", error); + throw new Error("Failed to get user information"); + } + } + + async validateToken(accessToken: string): Promise { + try { + await this.getUserInfo(accessToken); + return true; + } catch { + return false; + } + } +} diff --git a/server/src/types/express-session.d.ts b/server/src/types/express-session.d.ts new file mode 100644 index 0000000..def4b47 --- /dev/null +++ b/server/src/types/express-session.d.ts @@ -0,0 +1,9 @@ +import "express-session"; + +declare module "express-session" { + interface SessionData { + userId?: number; // internal DB ID + forgeType?: string; // e.g., "gitea" or "github" + codeVerifier?: string; // PKCE code verifier for OAuth + } +} \ No newline at end of file diff --git a/server/src/types/forge.ts b/server/src/types/forge.ts new file mode 100644 index 0000000..1872756 --- /dev/null +++ b/server/src/types/forge.ts @@ -0,0 +1,17 @@ +import type { User } from "../types/user"; +import type { Repository } from "../types/openapi"; + +export interface Forge { + getAuthorizationUrl(): { url: string; state: string; codeVerifier: string }; + + exchangeCodeForToken( + code: string, + codeVerifier: string, + ): Promise<{ accessToken: string }>; + + getUserInfo(accessToken: string): Promise; + + listRepositories(accessToken: string): Promise; + + validateToken(accessToken: string): Promise; +} diff --git a/server/src/types/openapi.d.ts b/server/src/types/openapi.d.ts index de3f6e7..240a237 100644 --- a/server/src/types/openapi.d.ts +++ b/server/src/types/openapi.d.ts @@ -1,139 +1,175 @@ -import type { - Context, - UnknownParams, -} from 'openapi-backend'; +import type { Context, UnknownParams } from "openapi-backend"; declare namespace Components { - namespace Schemas { - export interface Error { - /** - * Error message - * example: - * Repository not found - */ - error: string; - /** - * Error code for programmatic handling - * example: - * REPO_NOT_FOUND - */ - code: string; - /** - * Additional error details - */ - details?: { - [name: string]: any; - }; - } - export interface Repository { - /** - * example: - * 123 - */ - id?: string; - /** - * example: - * molci - */ - name?: string; - /** - * example: - * gitea - */ - forge?: "gitea"; - } - export interface RepositoryConfig { - repo?: Repository; - configured_at?: string; // date-time - webhook_id?: string; - } - export interface RepositoryConfigInput { - settings: { - [name: string]: any; - }; - } - } + namespace Schemas { + export interface Error { + /** + * Error message + * example: + * Repository not found + */ + error: string; + /** + * Error code for programmatic handling + * example: + * REPO_NOT_FOUND + */ + code: string; + /** + * Additional error details + */ + details?: { + [name: string]: any; + }; + } + export interface Repository { + /** + * example: + * 123 + */ + id?: string; + /** + * example: + * molci + */ + name?: string; + /** + * example: + * gitea + */ + forge?: "gitea"; + } + export interface RepositoryConfig { + repo?: Repository; + configured_at?: string; // date-time + webhook_id?: string; + } + export interface RepositoryConfigInput { + settings: { + [name: string]: any; + }; + } + } } declare namespace Paths { - namespace ConfigureRepo { - namespace Parameters { - export type Id = string; - } - export interface PathParameters { - id: Parameters.Id; - } - export type RequestBody = Components.Schemas.RepositoryConfigInput; - namespace Responses { - export type $200 = Components.Schemas.RepositoryConfig; - export type $400 = Components.Schemas.Error; - export type $401 = Components.Schemas.Error; - export type $404 = Components.Schemas.Error; - export type $500 = Components.Schemas.Error; - } - } - namespace ListAvailableRepos { - namespace Responses { - export type $200 = Components.Schemas.Repository[]; - export type $401 = Components.Schemas.Error; - export type $403 = Components.Schemas.Error; - export type $500 = Components.Schemas.Error; - } - } - namespace ListConfiguredRepos { - namespace Responses { - export type $200 = Components.Schemas.RepositoryConfig[]; - export type $401 = Components.Schemas.Error; - export type $500 = Components.Schemas.Error; - } - } + namespace ConfigureRepo { + namespace Parameters { + export type Id = string; + } + export interface PathParameters { + id: Parameters.Id; + } + export type RequestBody = Components.Schemas.RepositoryConfigInput; + namespace Responses { + export type $200 = Components.Schemas.RepositoryConfig; + export type $400 = Components.Schemas.Error; + export type $401 = Components.Schemas.Error; + export type $404 = Components.Schemas.Error; + export type $500 = Components.Schemas.Error; + } + } + namespace ListAvailableRepos { + namespace Responses { + export type $200 = Components.Schemas.Repository[]; + export type $401 = Components.Schemas.Error; + export type $403 = Components.Schemas.Error; + export type $500 = Components.Schemas.Error; + } + } + namespace ListConfiguredRepos { + namespace Responses { + export type $200 = Components.Schemas.RepositoryConfig[]; + export type $401 = Components.Schemas.Error; + export type $500 = Components.Schemas.Error; + } + } } - export interface Operations { - /** - * GET /repos/available - */ - ['listAvailableRepos']: { - requestBody: any; - params: UnknownParams; - query: UnknownParams; - headers: UnknownParams; - cookies: UnknownParams; - context: Context; - response: Paths.ListAvailableRepos.Responses.$200 | Paths.ListAvailableRepos.Responses.$401 | Paths.ListAvailableRepos.Responses.$403 | Paths.ListAvailableRepos.Responses.$500; - } - /** - * GET /repos/configured - */ - ['listConfiguredRepos']: { - requestBody: any; - params: UnknownParams; - query: UnknownParams; - headers: UnknownParams; - cookies: UnknownParams; - context: Context; - response: Paths.ListConfiguredRepos.Responses.$200 | Paths.ListConfiguredRepos.Responses.$401 | Paths.ListConfiguredRepos.Responses.$500; - } - /** - * PUT /repos/{id} - */ - ['configureRepo']: { - requestBody: Paths.ConfigureRepo.RequestBody; - params: Paths.ConfigureRepo.PathParameters; - query: UnknownParams; - headers: UnknownParams; - cookies: UnknownParams; - context: Context; - response: Paths.ConfigureRepo.Responses.$200 | Paths.ConfigureRepo.Responses.$400 | Paths.ConfigureRepo.Responses.$401 | Paths.ConfigureRepo.Responses.$404 | Paths.ConfigureRepo.Responses.$500; - } + /** + * GET /repos/available + */ + ["listAvailableRepos"]: { + requestBody: any; + params: UnknownParams; + query: UnknownParams; + headers: UnknownParams; + cookies: UnknownParams; + context: Context< + any, + UnknownParams, + UnknownParams, + UnknownParams, + UnknownParams + >; + response: + | Paths.ListAvailableRepos.Responses.$200 + | Paths.ListAvailableRepos.Responses.$401 + | Paths.ListAvailableRepos.Responses.$403 + | Paths.ListAvailableRepos.Responses.$500; + }; + /** + * GET /repos/configured + */ + ["listConfiguredRepos"]: { + requestBody: any; + params: UnknownParams; + query: UnknownParams; + headers: UnknownParams; + cookies: UnknownParams; + context: Context< + any, + UnknownParams, + UnknownParams, + UnknownParams, + UnknownParams + >; + response: + | Paths.ListConfiguredRepos.Responses.$200 + | Paths.ListConfiguredRepos.Responses.$401 + | Paths.ListConfiguredRepos.Responses.$500; + }; + /** + * PUT /repos/{id} + */ + ["configureRepo"]: { + requestBody: Paths.ConfigureRepo.RequestBody; + params: Paths.ConfigureRepo.PathParameters; + query: UnknownParams; + headers: UnknownParams; + cookies: UnknownParams; + context: Context< + Paths.ConfigureRepo.RequestBody, + Paths.ConfigureRepo.PathParameters, + UnknownParams, + UnknownParams, + UnknownParams + >; + response: + | Paths.ConfigureRepo.Responses.$200 + | Paths.ConfigureRepo.Responses.$400 + | Paths.ConfigureRepo.Responses.$401 + | Paths.ConfigureRepo.Responses.$404 + | Paths.ConfigureRepo.Responses.$500; + }; } -export type OperationContext = Operations[operationId]["context"]; -export type OperationResponse = Operations[operationId]["response"]; -export type HandlerResponse> = ResponseModel & { _t?: ResponseBody }; -export type OperationHandlerResponse = HandlerResponse>; -export type OperationHandler = (...params: [OperationContext, ...HandlerArgs]) => Promise>; - +export type OperationContext = + Operations[operationId]["context"]; +export type OperationResponse = + Operations[operationId]["response"]; +export type HandlerResponse< + ResponseBody, + ResponseModel = Record, +> = ResponseModel & { _t?: ResponseBody }; +export type OperationHandlerResponse = + HandlerResponse>; +export type OperationHandler< + operationId extends keyof Operations, + HandlerArgs extends unknown[] = unknown[], +> = ( + ...params: [OperationContext, ...HandlerArgs] +) => Promise>; export type Error = Components.Schemas.Error; export type Repository = Components.Schemas.Repository; diff --git a/server/src/types/user.ts b/server/src/types/user.ts new file mode 100644 index 0000000..89f9dcc --- /dev/null +++ b/server/src/types/user.ts @@ -0,0 +1,8 @@ +export interface User { + id: number | string; + login: string; + email?: string; + avatar_url?: string; +} + +// logic to insert user in db \ No newline at end of file