better api
This commit is contained in:
parent
1bfd12ceb2
commit
654b243ab8
@ -13,7 +13,8 @@
|
||||
"Bash(pnpm vitest run:*)",
|
||||
"Bash(npx tsc:*)",
|
||||
"Bash(pnpm db:generate:*)",
|
||||
"Bash(DATABASE_URL=\"postgresql://fedjens@localhost:5432/eyrun_test\" JWT_SECRET=\"test-secret-that-is-at-least-32-characters-long\" pnpm db:migrate:*)"
|
||||
"Bash(DATABASE_URL=\"postgresql://fedjens@localhost:5432/eyrun_test\" JWT_SECRET=\"test-secret-that-is-at-least-32-characters-long\" pnpm db:migrate:*)",
|
||||
"Bash(ls:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,3 +2,4 @@ DATABASE_URL=postgresql://user:password@localhost:5432/eyrun
|
||||
JWT_SECRET=change-me-to-a-random-string-at-least-32-chars
|
||||
PORT=3000
|
||||
HOST=0.0.0.0
|
||||
RESEND_API_KEY=your-resend-api-key-here
|
||||
@ -25,6 +25,7 @@
|
||||
"fastify-plugin": "^5.1.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"postgres": "^3.4.8",
|
||||
"resend": "^6.9.1",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
255
pnpm-lock.yaml
generated
255
pnpm-lock.yaml
generated
@ -29,6 +29,9 @@ importers:
|
||||
postgres:
|
||||
specifier: ^3.4.8
|
||||
version: 3.4.8
|
||||
resend:
|
||||
specifier: ^6.9.1
|
||||
version: 6.9.1
|
||||
zod:
|
||||
specifier: ^4.3.6
|
||||
version: 4.3.6
|
||||
@ -692,6 +695,12 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@selderee/plugin-htmlparser2@0.11.0':
|
||||
resolution: {integrity: sha512-P33hHGdldxGabLFjPPpaTxVolMrzrcegejx+0GxjrIb9Zv48D8yAIA/QTDR2dFl7Uz7urX8aX6+5bCZslr+gWQ==}
|
||||
|
||||
'@stablelib/base64@1.0.1':
|
||||
resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==}
|
||||
|
||||
'@standard-schema/spec@1.1.0':
|
||||
resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==}
|
||||
|
||||
@ -751,6 +760,9 @@ packages:
|
||||
'@vitest/utils@4.0.18':
|
||||
resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==}
|
||||
|
||||
'@zone-eu/mailsplit@5.4.8':
|
||||
resolution: {integrity: sha512-eEyACj4JZ7sjzRvy26QhLgKEMWwQbsw1+QZnlLX+/gihcNH07lVPOcnwf5U6UAL7gkc//J3jVd76o/WS+taUiA==}
|
||||
|
||||
abstract-logging@2.0.1:
|
||||
resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==}
|
||||
|
||||
@ -802,10 +814,27 @@ packages:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
deepmerge@4.3.1:
|
||||
resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
dequal@2.0.3:
|
||||
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
dom-serializer@2.0.0:
|
||||
resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
|
||||
|
||||
domelementtype@2.3.0:
|
||||
resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
|
||||
|
||||
domhandler@5.0.3:
|
||||
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
domutils@3.2.2:
|
||||
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
|
||||
|
||||
dotenv@17.2.4:
|
||||
resolution: {integrity: sha512-mudtfb4zRB4bVvdj0xRo+e6duH1csJRM8IukBqfTRvHotn9+LBXB8ynAidP9zHqoRC/fsllXgk4kCKlR21fIhw==}
|
||||
engines: {node: '>=12'}
|
||||
@ -909,6 +938,14 @@ packages:
|
||||
ecdsa-sig-formatter@1.0.11:
|
||||
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
|
||||
|
||||
encoding-japanese@2.2.0:
|
||||
resolution: {integrity: sha512-EuJWwlHPZ1LbADuKTClvHtwbaFn4rOD+dRAbWysqEOXRc2Uui0hJInNJrsdH0c+OhJA4nrCBdSkW4DD5YxAo6A==}
|
||||
engines: {node: '>=8.10.0'}
|
||||
|
||||
entities@4.5.0:
|
||||
resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
es-module-lexer@1.7.0:
|
||||
resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==}
|
||||
|
||||
@ -951,6 +988,9 @@ packages:
|
||||
fast-querystring@1.1.2:
|
||||
resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==}
|
||||
|
||||
fast-sha256@1.3.0:
|
||||
resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==}
|
||||
|
||||
fast-uri@3.1.0:
|
||||
resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==}
|
||||
|
||||
@ -988,9 +1028,28 @@ packages:
|
||||
resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
he@1.2.0:
|
||||
resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
|
||||
hasBin: true
|
||||
|
||||
html-escaper@2.0.2:
|
||||
resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==}
|
||||
|
||||
html-to-text@9.0.5:
|
||||
resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
htmlparser2@8.0.2:
|
||||
resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==}
|
||||
|
||||
iconv-lite@0.6.3:
|
||||
resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
iconv-lite@0.7.0:
|
||||
resolution: {integrity: sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
ipaddr.js@2.3.0:
|
||||
resolution: {integrity: sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg==}
|
||||
engines: {node: '>= 10'}
|
||||
@ -1030,9 +1089,24 @@ packages:
|
||||
jws@4.0.1:
|
||||
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
|
||||
|
||||
leac@0.6.0:
|
||||
resolution: {integrity: sha512-y+SqErxb8h7nE/fiEX07jsbuhrpO9lL8eca7/Y1nuWV2moNlXhyd59iDGcRf6moVyDMbmTNzL40SUyrFU/yDpg==}
|
||||
|
||||
libbase64@1.3.0:
|
||||
resolution: {integrity: sha512-GgOXd0Eo6phYgh0DJtjQ2tO8dc0IVINtZJeARPeiIJqge+HdsWSuaDTe8ztQ7j/cONByDZ3zeB325AHiv5O0dg==}
|
||||
|
||||
libmime@5.3.7:
|
||||
resolution: {integrity: sha512-FlDb3Wtha8P01kTL3P9M+ZDNDWPKPmKHWaU/cG/lg5pfuAwdflVpZE+wm9m7pKmC5ww6s+zTxBKS1p6yl3KpSw==}
|
||||
|
||||
libqp@2.1.1:
|
||||
resolution: {integrity: sha512-0Wd+GPz1O134cP62YU2GTOPNA7Qgl09XwCqM5zpBv87ERCXdfDtyKXvV7c9U22yWJh44QZqBocFnXN11K96qow==}
|
||||
|
||||
light-my-request@6.6.0:
|
||||
resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==}
|
||||
|
||||
linkify-it@5.0.0:
|
||||
resolution: {integrity: sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==}
|
||||
|
||||
lodash.includes@4.3.0:
|
||||
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
|
||||
|
||||
@ -1060,6 +1134,9 @@ packages:
|
||||
magicast@0.5.2:
|
||||
resolution: {integrity: sha512-E3ZJh4J3S9KfwdjZhe2afj6R9lGIN5Pher1pF39UGrXRqq/VDaGVIGN13BjHd2u8B61hArAGOnso7nBOouW3TQ==}
|
||||
|
||||
mailparser@3.9.1:
|
||||
resolution: {integrity: sha512-6vHZcco3fWsDMkf4Vz9iAfxvwrKNGbHx0dV1RKVphQ/zaNY34Buc7D37LSa09jeSeybWzYcTPjhiZFxzVRJedA==}
|
||||
|
||||
make-dir@4.0.0:
|
||||
resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==}
|
||||
engines: {node: '>=10'}
|
||||
@ -1072,6 +1149,10 @@ packages:
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
nodemailer@7.0.11:
|
||||
resolution: {integrity: sha512-gnXhNRE0FNhD7wPSCGhdNh46Hs6nm+uTyg+Kq0cZukNQiYdnCsoQjodNP9BQVG9XrcK/v6/MgpAPBUFyzh9pvw==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
obug@2.1.1:
|
||||
resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==}
|
||||
|
||||
@ -1082,9 +1163,15 @@ packages:
|
||||
openapi-types@12.1.3:
|
||||
resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==}
|
||||
|
||||
parseley@0.12.1:
|
||||
resolution: {integrity: sha512-e6qHKe3a9HWr0oMRVDTRhKce+bRO8VGQR3NyVwcjwrbhMmFCX9KszEV35+rn4AdilFAq9VPxP/Fe1wC9Qjd2lw==}
|
||||
|
||||
pathe@2.0.3:
|
||||
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||
|
||||
peberminta@0.9.0:
|
||||
resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==}
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
@ -1116,6 +1203,10 @@ packages:
|
||||
process-warning@5.0.0:
|
||||
resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==}
|
||||
|
||||
punycode.js@2.3.1:
|
||||
resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
quick-format-unescaped@4.0.4:
|
||||
resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==}
|
||||
|
||||
@ -1127,6 +1218,15 @@ packages:
|
||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
resend@6.9.1:
|
||||
resolution: {integrity: sha512-jFY3qPP2cith1npRXvS7PVdnhbR1CcuzHg65ty5Elv55GKiXhe+nItXuzzoOlKeYJez1iJAo2+8f6ae8sCj0iA==}
|
||||
engines: {node: '>=20'}
|
||||
peerDependencies:
|
||||
'@react-email/render': '*'
|
||||
peerDependenciesMeta:
|
||||
'@react-email/render':
|
||||
optional: true
|
||||
|
||||
resolve-pkg-maps@1.0.0:
|
||||
resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
|
||||
|
||||
@ -1156,9 +1256,15 @@ packages:
|
||||
resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
safer-buffer@2.1.2:
|
||||
resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
|
||||
|
||||
secure-json-parse@4.1.0:
|
||||
resolution: {integrity: sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==}
|
||||
|
||||
selderee@0.11.0:
|
||||
resolution: {integrity: sha512-5TF+l7p4+OsnP8BCCvSyZiSPc4x4//p5uPwK8TCnVPJYRmU2aYKMpOXvw8zM5a5JvuuCGN1jmsMwuU2W02ukfA==}
|
||||
|
||||
semver@7.7.4:
|
||||
resolution: {integrity: sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==}
|
||||
engines: {node: '>=10'}
|
||||
@ -1191,6 +1297,9 @@ packages:
|
||||
stackback@0.0.2:
|
||||
resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==}
|
||||
|
||||
standardwebhooks@1.0.0:
|
||||
resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==}
|
||||
|
||||
std-env@3.10.0:
|
||||
resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==}
|
||||
|
||||
@ -1198,6 +1307,9 @@ packages:
|
||||
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
svix@1.84.1:
|
||||
resolution: {integrity: sha512-K8DPPSZaW/XqXiz1kEyzSHYgmGLnhB43nQCMeKjWGCUpLIpAMMM8kx3rVVOSm6Bo6EHyK1RQLPT4R06skM/MlQ==}
|
||||
|
||||
thread-stream@4.0.0:
|
||||
resolution: {integrity: sha512-4iMVL6HAINXWf1ZKZjIPcz5wYaOdPhtO8ATvZ+Xqp3BTdaqtAwQkNmKORqcIo5YkQqGXq5cwfswDwMqqQNrpJA==}
|
||||
engines: {node: '>=20'}
|
||||
@ -1217,6 +1329,10 @@ packages:
|
||||
resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
tlds@1.261.0:
|
||||
resolution: {integrity: sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==}
|
||||
hasBin: true
|
||||
|
||||
toad-cache@3.7.0:
|
||||
resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==}
|
||||
engines: {node: '>=12'}
|
||||
@ -1231,9 +1347,16 @@ packages:
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
uc.micro@2.1.0:
|
||||
resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==}
|
||||
|
||||
undici-types@7.16.0:
|
||||
resolution: {integrity: sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==}
|
||||
|
||||
uuid@10.0.0:
|
||||
resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
|
||||
hasBin: true
|
||||
|
||||
vite@7.3.1:
|
||||
resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
@ -1691,6 +1814,13 @@ snapshots:
|
||||
'@rollup/rollup-win32-x64-msvc@4.57.1':
|
||||
optional: true
|
||||
|
||||
'@selderee/plugin-htmlparser2@0.11.0':
|
||||
dependencies:
|
||||
domhandler: 5.0.3
|
||||
selderee: 0.11.0
|
||||
|
||||
'@stablelib/base64@1.0.1': {}
|
||||
|
||||
'@standard-schema/spec@1.1.0': {}
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
@ -1766,6 +1896,12 @@ snapshots:
|
||||
'@vitest/pretty-format': 4.0.18
|
||||
tinyrainbow: 3.0.3
|
||||
|
||||
'@zone-eu/mailsplit@5.4.8':
|
||||
dependencies:
|
||||
libbase64: 1.3.0
|
||||
libmime: 5.3.7
|
||||
libqp: 2.1.1
|
||||
|
||||
abstract-logging@2.0.1: {}
|
||||
|
||||
ajv-formats@3.0.1(ajv@8.17.1):
|
||||
@ -1806,8 +1942,28 @@ snapshots:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
deepmerge@4.3.1: {}
|
||||
|
||||
dequal@2.0.3: {}
|
||||
|
||||
dom-serializer@2.0.0:
|
||||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
domhandler: 5.0.3
|
||||
entities: 4.5.0
|
||||
|
||||
domelementtype@2.3.0: {}
|
||||
|
||||
domhandler@5.0.3:
|
||||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
|
||||
domutils@3.2.2:
|
||||
dependencies:
|
||||
dom-serializer: 2.0.0
|
||||
domelementtype: 2.3.0
|
||||
domhandler: 5.0.3
|
||||
|
||||
dotenv@17.2.4: {}
|
||||
|
||||
drizzle-kit@0.31.8:
|
||||
@ -1827,6 +1983,10 @@ snapshots:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
encoding-japanese@2.2.0: {}
|
||||
|
||||
entities@4.5.0: {}
|
||||
|
||||
es-module-lexer@1.7.0: {}
|
||||
|
||||
esbuild-register@3.6.0(esbuild@0.25.12):
|
||||
@ -1942,6 +2102,8 @@ snapshots:
|
||||
dependencies:
|
||||
fast-decode-uri-component: 1.0.1
|
||||
|
||||
fast-sha256@1.3.0: {}
|
||||
|
||||
fast-uri@3.1.0: {}
|
||||
|
||||
fastify-plugin@5.1.0: {}
|
||||
@ -1987,8 +2149,33 @@ snapshots:
|
||||
|
||||
has-flag@4.0.0: {}
|
||||
|
||||
he@1.2.0: {}
|
||||
|
||||
html-escaper@2.0.2: {}
|
||||
|
||||
html-to-text@9.0.5:
|
||||
dependencies:
|
||||
'@selderee/plugin-htmlparser2': 0.11.0
|
||||
deepmerge: 4.3.1
|
||||
dom-serializer: 2.0.0
|
||||
htmlparser2: 8.0.2
|
||||
selderee: 0.11.0
|
||||
|
||||
htmlparser2@8.0.2:
|
||||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
domhandler: 5.0.3
|
||||
domutils: 3.2.2
|
||||
entities: 4.5.0
|
||||
|
||||
iconv-lite@0.6.3:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
|
||||
iconv-lite@0.7.0:
|
||||
dependencies:
|
||||
safer-buffer: 2.1.2
|
||||
|
||||
ipaddr.js@2.3.0: {}
|
||||
|
||||
istanbul-lib-coverage@3.2.2: {}
|
||||
@ -2044,12 +2231,29 @@ snapshots:
|
||||
jwa: 2.0.1
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
leac@0.6.0: {}
|
||||
|
||||
libbase64@1.3.0: {}
|
||||
|
||||
libmime@5.3.7:
|
||||
dependencies:
|
||||
encoding-japanese: 2.2.0
|
||||
iconv-lite: 0.6.3
|
||||
libbase64: 1.3.0
|
||||
libqp: 2.1.1
|
||||
|
||||
libqp@2.1.1: {}
|
||||
|
||||
light-my-request@6.6.0:
|
||||
dependencies:
|
||||
cookie: 1.1.1
|
||||
process-warning: 4.0.1
|
||||
set-cookie-parser: 2.7.2
|
||||
|
||||
linkify-it@5.0.0:
|
||||
dependencies:
|
||||
uc.micro: 2.1.0
|
||||
|
||||
lodash.includes@4.3.0: {}
|
||||
|
||||
lodash.isboolean@3.0.3: {}
|
||||
@ -2074,6 +2278,19 @@ snapshots:
|
||||
'@babel/types': 7.29.0
|
||||
source-map-js: 1.2.1
|
||||
|
||||
mailparser@3.9.1:
|
||||
dependencies:
|
||||
'@zone-eu/mailsplit': 5.4.8
|
||||
encoding-japanese: 2.2.0
|
||||
he: 1.2.0
|
||||
html-to-text: 9.0.5
|
||||
iconv-lite: 0.7.0
|
||||
libmime: 5.3.7
|
||||
linkify-it: 5.0.0
|
||||
nodemailer: 7.0.11
|
||||
punycode.js: 2.3.1
|
||||
tlds: 1.261.0
|
||||
|
||||
make-dir@4.0.0:
|
||||
dependencies:
|
||||
semver: 7.7.4
|
||||
@ -2082,14 +2299,23 @@ snapshots:
|
||||
|
||||
nanoid@3.3.11: {}
|
||||
|
||||
nodemailer@7.0.11: {}
|
||||
|
||||
obug@2.1.1: {}
|
||||
|
||||
on-exit-leak-free@2.1.2: {}
|
||||
|
||||
openapi-types@12.1.3: {}
|
||||
|
||||
parseley@0.12.1:
|
||||
dependencies:
|
||||
leac: 0.6.0
|
||||
peberminta: 0.9.0
|
||||
|
||||
pathe@2.0.3: {}
|
||||
|
||||
peberminta@0.9.0: {}
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
picomatch@4.0.3: {}
|
||||
@ -2126,12 +2352,19 @@ snapshots:
|
||||
|
||||
process-warning@5.0.0: {}
|
||||
|
||||
punycode.js@2.3.1: {}
|
||||
|
||||
quick-format-unescaped@4.0.4: {}
|
||||
|
||||
real-require@0.2.0: {}
|
||||
|
||||
require-from-string@2.0.2: {}
|
||||
|
||||
resend@6.9.1:
|
||||
dependencies:
|
||||
mailparser: 3.9.1
|
||||
svix: 1.84.1
|
||||
|
||||
resolve-pkg-maps@1.0.0: {}
|
||||
|
||||
ret@0.5.0: {}
|
||||
@ -2179,8 +2412,14 @@ snapshots:
|
||||
|
||||
safe-stable-stringify@2.5.0: {}
|
||||
|
||||
safer-buffer@2.1.2: {}
|
||||
|
||||
secure-json-parse@4.1.0: {}
|
||||
|
||||
selderee@0.11.0:
|
||||
dependencies:
|
||||
parseley: 0.12.1
|
||||
|
||||
semver@7.7.4: {}
|
||||
|
||||
set-cookie-parser@2.7.2: {}
|
||||
@ -2204,12 +2443,22 @@ snapshots:
|
||||
|
||||
stackback@0.0.2: {}
|
||||
|
||||
standardwebhooks@1.0.0:
|
||||
dependencies:
|
||||
'@stablelib/base64': 1.0.1
|
||||
fast-sha256: 1.3.0
|
||||
|
||||
std-env@3.10.0: {}
|
||||
|
||||
supports-color@7.2.0:
|
||||
dependencies:
|
||||
has-flag: 4.0.0
|
||||
|
||||
svix@1.84.1:
|
||||
dependencies:
|
||||
standardwebhooks: 1.0.0
|
||||
uuid: 10.0.0
|
||||
|
||||
thread-stream@4.0.0:
|
||||
dependencies:
|
||||
real-require: 0.2.0
|
||||
@ -2225,6 +2474,8 @@ snapshots:
|
||||
|
||||
tinyrainbow@3.0.3: {}
|
||||
|
||||
tlds@1.261.0: {}
|
||||
|
||||
toad-cache@3.7.0: {}
|
||||
|
||||
tsx@4.21.0:
|
||||
@ -2236,8 +2487,12 @@ snapshots:
|
||||
|
||||
typescript@5.9.3: {}
|
||||
|
||||
uc.micro@2.1.0: {}
|
||||
|
||||
undici-types@7.16.0: {}
|
||||
|
||||
uuid@10.0.0: {}
|
||||
|
||||
vite@7.3.1(@types/node@25.2.1)(tsx@4.21.0)(yaml@2.8.2):
|
||||
dependencies:
|
||||
esbuild: 0.27.3
|
||||
|
||||
@ -7,6 +7,7 @@ const envSchema = z.object({
|
||||
PORT: z.coerce.number().default(3000),
|
||||
HOST: z.string().default("0.0.0.0"),
|
||||
API_URL: z.url().optional(),
|
||||
RESEND_API_KEY: z.string().min(1),
|
||||
});
|
||||
|
||||
const parsed = envSchema.safeParse(process.env);
|
||||
|
||||
@ -1,8 +1,12 @@
|
||||
import crypto from "node:crypto";
|
||||
import { eq, and, gt, lt, sql } from "drizzle-orm";
|
||||
import { Resend } from "resend";
|
||||
import { config } from "../config.js";
|
||||
import { db } from "../db/index.js";
|
||||
import { otpCodes } from "../db/schema.js";
|
||||
|
||||
const resend = new Resend(config.RESEND_API_KEY);
|
||||
|
||||
const OTP_MAX_ATTEMPTS = 5;
|
||||
const OTP_TTL_MINUTES = 10;
|
||||
const OTP_RATE_LIMIT_PER_HOUR = 3;
|
||||
@ -23,11 +27,20 @@ export async function requestOtp(email: string): Promise<void> {
|
||||
const expiresAt = new Date(Date.now() + OTP_TTL_MINUTES * 60 * 1000);
|
||||
|
||||
await db.insert(otpCodes).values({ email, code, expiresAt });
|
||||
sendOtp(email, code);
|
||||
await sendOtp(email, code);
|
||||
}
|
||||
|
||||
function sendOtp(email: string, code: string): void {
|
||||
console.log(`[OTP] Code for ${email}: ${code}`);
|
||||
async function sendOtp(email: string, code: string): Promise<void> {
|
||||
const { error } = await resend.emails.send({
|
||||
from: "Eyrun <hello@eyrun.dev>",
|
||||
to: email,
|
||||
subject: "Your verification code",
|
||||
html: `<p>Your verification code is: <strong>${code}</strong></p><p>This code expires in 10 minutes.</p>`,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw new Error(`Failed to send OTP email: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate an OTP code for the given email. Throws on failure. */
|
||||
|
||||
@ -19,9 +19,9 @@ async function accountContextPlugin(app: FastifyInstance) {
|
||||
app.decorateRequest("membership", null as unknown as { role: string });
|
||||
|
||||
app.decorate("requireAccount", async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
const accountId = request.headers["x-account-id"];
|
||||
const accountId = (request.params as Record<string, string>).accountId;
|
||||
if (!accountId || typeof accountId !== "string") {
|
||||
return reply.status(400).send({ error: "X-Account-Id header is required" });
|
||||
return reply.status(400).send({ error: "accountId path parameter is required" });
|
||||
}
|
||||
|
||||
const [membership] = await db
|
||||
|
||||
@ -13,10 +13,10 @@ export async function registerRoutes(app: FastifyInstance) {
|
||||
app.register(healthRoutes);
|
||||
app.register(loginRoutes);
|
||||
app.register(signupRoutes);
|
||||
app.register(sessionRoutes, { prefix: "/sessions" });
|
||||
app.register(sessionRoutes);
|
||||
app.register(meRoutes);
|
||||
app.register(accountRoutes);
|
||||
app.register(projectRoutes);
|
||||
app.register(projectRoutes, { prefix: "/accounts/:accountId" });
|
||||
app.register(packageRoutes);
|
||||
app.register(wmRoutes);
|
||||
app.register(wmRoutes, { prefix: "/accounts/:accountId" });
|
||||
}
|
||||
|
||||
@ -1,13 +1,18 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { requestOtp, OtpRateLimitError } from "../lib/otp.js";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "../db/index.js";
|
||||
import { users } from "../db/schema.js";
|
||||
import { requestOtp, OtpRateLimitError, verifyOtp, OtpError } from "../lib/otp.js";
|
||||
import { createSession } from "../lib/sessions.js";
|
||||
import { getUserAccounts } from "../lib/accounts.js";
|
||||
|
||||
export async function loginRoutes(app: FastifyInstance) {
|
||||
// POST /login — send OTP to email
|
||||
// POST /login — send OTP to existing user
|
||||
app.post<{ Body: { email: string } }>(
|
||||
"/login",
|
||||
{
|
||||
schema: {
|
||||
description: "Request an OTP (One-Time Password) for login",
|
||||
description: "Request an OTP for an existing user",
|
||||
tags: ["Authentication"],
|
||||
body: {
|
||||
type: "object",
|
||||
@ -24,6 +29,13 @@ export async function loginRoutes(app: FastifyInstance) {
|
||||
message: { type: "string" },
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description: "User not found",
|
||||
type: "object",
|
||||
properties: {
|
||||
error: { type: "string" },
|
||||
},
|
||||
},
|
||||
429: {
|
||||
description: "Too many OTP requests",
|
||||
type: "object",
|
||||
@ -37,6 +49,11 @@ export async function loginRoutes(app: FastifyInstance) {
|
||||
async (request, reply) => {
|
||||
const { email } = request.body;
|
||||
|
||||
const [user] = await db.select().from(users).where(eq(users.email, email));
|
||||
if (!user) {
|
||||
return reply.status(404).send({ error: "No account found. Please sign up." });
|
||||
}
|
||||
|
||||
try {
|
||||
await requestOtp(email);
|
||||
} catch (err) {
|
||||
@ -49,4 +66,88 @@ export async function loginRoutes(app: FastifyInstance) {
|
||||
return { message: "OTP sent to your email" };
|
||||
},
|
||||
);
|
||||
|
||||
// POST /login/verify — verify OTP, create session for existing user
|
||||
app.post<{ Body: { email: string; code: string } }>(
|
||||
"/login/verify",
|
||||
{
|
||||
schema: {
|
||||
description: "Verify OTP and create a session for an existing user",
|
||||
tags: ["Authentication"],
|
||||
body: {
|
||||
type: "object",
|
||||
required: ["email", "code"],
|
||||
properties: {
|
||||
email: { type: "string", format: "email" },
|
||||
code: { type: "string", minLength: 6, maxLength: 6 },
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
description: "Session created successfully",
|
||||
type: "object",
|
||||
properties: {
|
||||
accessToken: { type: "string" },
|
||||
refreshToken: { type: "string" },
|
||||
user: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
email: { type: "string" },
|
||||
name: { type: "string" },
|
||||
},
|
||||
},
|
||||
accounts: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
name: { type: "string" },
|
||||
role: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description: "Invalid or expired OTP",
|
||||
type: "object",
|
||||
properties: {
|
||||
error: { type: "string" },
|
||||
},
|
||||
},
|
||||
401: {
|
||||
description: "User not found",
|
||||
type: "object",
|
||||
properties: {
|
||||
error: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { email, code } = request.body;
|
||||
|
||||
try {
|
||||
await verifyOtp(email, code);
|
||||
} catch (err) {
|
||||
if (err instanceof OtpError) {
|
||||
return reply.status(400).send({ error: err.message });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const [user] = await db.select().from(users).where(eq(users.email, email));
|
||||
if (!user) {
|
||||
return reply.status(401).send({ error: "No account found. Please sign up." });
|
||||
}
|
||||
|
||||
const tokens = await createSession(user.id);
|
||||
const userAccounts = await getUserAccounts(user.id);
|
||||
|
||||
return { ...tokens, user, accounts: userAccounts };
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -13,6 +13,12 @@ export async function projectRoutes(app: FastifyInstance) {
|
||||
description: "List all projects for the current account",
|
||||
tags: ["Projects"],
|
||||
security: [{ bearerAuth: [] }],
|
||||
params: {
|
||||
type: "object",
|
||||
properties: {
|
||||
accountId: { type: "string", format: "uuid" },
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
description: "List of projects",
|
||||
@ -47,6 +53,12 @@ export async function projectRoutes(app: FastifyInstance) {
|
||||
description: "Create a new project in the current account",
|
||||
tags: ["Projects"],
|
||||
security: [{ bearerAuth: [] }],
|
||||
params: {
|
||||
type: "object",
|
||||
properties: {
|
||||
accountId: { type: "string", format: "uuid" },
|
||||
},
|
||||
},
|
||||
body: {
|
||||
type: "object",
|
||||
required: ["name"],
|
||||
@ -91,6 +103,7 @@ export async function projectRoutes(app: FastifyInstance) {
|
||||
params: {
|
||||
type: "object",
|
||||
properties: {
|
||||
accountId: { type: "string", format: "uuid" },
|
||||
id: { type: "string", format: "uuid" },
|
||||
},
|
||||
},
|
||||
@ -152,6 +165,7 @@ export async function projectRoutes(app: FastifyInstance) {
|
||||
params: {
|
||||
type: "object",
|
||||
properties: {
|
||||
accountId: { type: "string", format: "uuid" },
|
||||
id: { type: "string", format: "uuid" },
|
||||
},
|
||||
},
|
||||
|
||||
@ -2,96 +2,11 @@ import type { FastifyInstance } from "fastify";
|
||||
import { eq, and, isNull } from "drizzle-orm";
|
||||
import { db } from "../db/index.js";
|
||||
import { users, sessions } from "../db/schema.js";
|
||||
import { verifyOtp, OtpError } from "../lib/otp.js";
|
||||
import { createSession } from "../lib/sessions.js";
|
||||
import { getUserAccounts } from "../lib/accounts.js";
|
||||
|
||||
export async function sessionRoutes(app: FastifyInstance) {
|
||||
// POST /sessions — validate OTP, create session for existing user
|
||||
app.post<{ Body: { email: string; code: string } }>(
|
||||
"/",
|
||||
{
|
||||
schema: {
|
||||
description: "Create a new session for an existing user with OTP verification",
|
||||
tags: ["Authentication"],
|
||||
body: {
|
||||
type: "object",
|
||||
required: ["email", "code"],
|
||||
properties: {
|
||||
email: { type: "string", format: "email" },
|
||||
code: { type: "string", minLength: 6, maxLength: 6 },
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
description: "Session created successfully",
|
||||
type: "object",
|
||||
properties: {
|
||||
accessToken: { type: "string" },
|
||||
refreshToken: { type: "string" },
|
||||
user: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
email: { type: "string" },
|
||||
name: { type: "string" },
|
||||
},
|
||||
},
|
||||
accounts: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
id: { type: "string" },
|
||||
name: { type: "string" },
|
||||
role: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description: "Invalid or expired OTP",
|
||||
type: "object",
|
||||
properties: {
|
||||
error: { type: "string" },
|
||||
},
|
||||
},
|
||||
401: {
|
||||
description: "User not found or invalid credentials",
|
||||
type: "object",
|
||||
properties: {
|
||||
error: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { email, code } = request.body;
|
||||
|
||||
try {
|
||||
await verifyOtp(email, code);
|
||||
} catch (err) {
|
||||
if (err instanceof OtpError) {
|
||||
return reply.status(400).send({ error: err.message });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
const [user] = await db.select().from(users).where(eq(users.email, email));
|
||||
if (!user) {
|
||||
return reply.status(401).send({ error: "No account found. Please sign up." });
|
||||
}
|
||||
|
||||
const tokens = await createSession(user.id);
|
||||
const userAccounts = await getUserAccounts(user.id);
|
||||
|
||||
return { ...tokens, user, accounts: userAccounts };
|
||||
},
|
||||
);
|
||||
|
||||
// POST /sessions/refresh — rotate refresh token, return new tokens
|
||||
// POST /refresh — rotate refresh token, return new tokens
|
||||
app.post<{ Body: { refreshToken: string } }>(
|
||||
"/refresh",
|
||||
{
|
||||
@ -184,7 +99,7 @@ export async function sessionRoutes(app: FastifyInstance) {
|
||||
},
|
||||
);
|
||||
|
||||
// POST /sessions/logout — revoke all sessions (authenticated)
|
||||
// POST /logout — revoke all sessions (authenticated)
|
||||
app.post(
|
||||
"/logout",
|
||||
{
|
||||
|
||||
@ -2,25 +2,86 @@ import type { FastifyInstance } from "fastify";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "../db/index.js";
|
||||
import { users, accounts, memberships } from "../db/schema.js";
|
||||
import { verifyOtp, OtpError } from "../lib/otp.js";
|
||||
import { requestOtp, OtpRateLimitError, verifyOtp, OtpError } from "../lib/otp.js";
|
||||
import { createSession } from "../lib/sessions.js";
|
||||
import { getUserAccounts } from "../lib/accounts.js";
|
||||
|
||||
export async function signupRoutes(app: FastifyInstance) {
|
||||
// POST /signup — validate OTP, create user + account, return tokens
|
||||
app.post<{ Body: { email: string; code: string; accountName: string } }>(
|
||||
// POST /signup — send OTP for a new user
|
||||
app.post<{ Body: { email: string } }>(
|
||||
"/signup",
|
||||
{
|
||||
schema: {
|
||||
description: "Sign up a new user with OTP verification",
|
||||
description: "Request an OTP for a new user signup",
|
||||
tags: ["Authentication"],
|
||||
body: {
|
||||
type: "object",
|
||||
required: ["email", "code", "accountName"],
|
||||
required: ["email"],
|
||||
properties: {
|
||||
email: { type: "string", format: "email" },
|
||||
},
|
||||
},
|
||||
response: {
|
||||
200: {
|
||||
description: "OTP sent successfully",
|
||||
type: "object",
|
||||
properties: {
|
||||
message: { type: "string" },
|
||||
},
|
||||
},
|
||||
409: {
|
||||
description: "User already exists",
|
||||
type: "object",
|
||||
properties: {
|
||||
error: { type: "string" },
|
||||
},
|
||||
},
|
||||
429: {
|
||||
description: "Too many OTP requests",
|
||||
type: "object",
|
||||
properties: {
|
||||
error: { type: "string" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { email } = request.body;
|
||||
|
||||
const [existingUser] = await db.select().from(users).where(eq(users.email, email));
|
||||
if (existingUser) {
|
||||
return reply.status(409).send({ error: "User already exists. Please log in." });
|
||||
}
|
||||
|
||||
try {
|
||||
await requestOtp(email);
|
||||
} catch (err) {
|
||||
if (err instanceof OtpRateLimitError) {
|
||||
return reply.status(429).send({ error: err.message });
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
return { message: "OTP sent to your email" };
|
||||
},
|
||||
);
|
||||
|
||||
// POST /signup/verify — verify OTP, create user + account + session
|
||||
app.post<{ Body: { email: string; code: string; accountName: string; username: string } }>(
|
||||
"/signup/verify",
|
||||
{
|
||||
schema: {
|
||||
description: "Verify OTP and create a new user with an account",
|
||||
tags: ["Authentication"],
|
||||
body: {
|
||||
type: "object",
|
||||
required: ["email", "code", "accountName", "username"],
|
||||
properties: {
|
||||
email: { type: "string", format: "email" },
|
||||
code: { type: "string", minLength: 6, maxLength: 6 },
|
||||
accountName: { type: "string", minLength: 1 },
|
||||
username: { type: "string", minLength: 1 },
|
||||
},
|
||||
},
|
||||
response: {
|
||||
@ -69,7 +130,7 @@ export async function signupRoutes(app: FastifyInstance) {
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { email, code, accountName } = request.body;
|
||||
const { email, code, accountName, username } = request.body;
|
||||
|
||||
try {
|
||||
await verifyOtp(email, code);
|
||||
@ -80,7 +141,6 @@ export async function signupRoutes(app: FastifyInstance) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Check that user does not already exist
|
||||
const [existingUser] = await db.select().from(users).where(eq(users.email, email));
|
||||
if (existingUser) {
|
||||
return reply.status(409).send({ error: "User already exists. Please log in." });
|
||||
@ -89,7 +149,7 @@ export async function signupRoutes(app: FastifyInstance) {
|
||||
const { user, tokens } = await db.transaction(async (tx) => {
|
||||
const [created] = await tx
|
||||
.insert(users)
|
||||
.values({ email, name: email.split("@")[0] })
|
||||
.values({ email, name: username })
|
||||
.returning();
|
||||
|
||||
const [account] = await tx.insert(accounts).values({ name: accountName }).returning();
|
||||
@ -105,9 +165,7 @@ export async function signupRoutes(app: FastifyInstance) {
|
||||
|
||||
const userAccounts = await getUserAccounts(user.id);
|
||||
|
||||
return reply
|
||||
.status(201)
|
||||
.send({ ...tokens, user, accounts: userAccounts });
|
||||
return reply.status(201).send({ ...tokens, user, accounts: userAccounts });
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -16,6 +16,7 @@ export async function wmRoutes(app: FastifyInstance) {
|
||||
params: {
|
||||
type: "object",
|
||||
properties: {
|
||||
accountId: { type: "string", format: "uuid" },
|
||||
projectId: { type: "string", format: "uuid" },
|
||||
},
|
||||
},
|
||||
@ -79,6 +80,7 @@ export async function wmRoutes(app: FastifyInstance) {
|
||||
params: {
|
||||
type: "object",
|
||||
properties: {
|
||||
accountId: { type: "string", format: "uuid" },
|
||||
projectId: { type: "string", format: "uuid" },
|
||||
},
|
||||
},
|
||||
@ -181,6 +183,7 @@ export async function wmRoutes(app: FastifyInstance) {
|
||||
params: {
|
||||
type: "object",
|
||||
properties: {
|
||||
accountId: { type: "string", format: "uuid" },
|
||||
projectId: { type: "string", format: "uuid" },
|
||||
id: { type: "string", format: "uuid" },
|
||||
},
|
||||
@ -264,6 +267,7 @@ export async function wmRoutes(app: FastifyInstance) {
|
||||
params: {
|
||||
type: "object",
|
||||
properties: {
|
||||
accountId: { type: "string", format: "uuid" },
|
||||
projectId: { type: "string", format: "uuid" },
|
||||
id: { type: "string", format: "uuid" },
|
||||
},
|
||||
|
||||
@ -12,7 +12,7 @@ beforeAll(async () => {
|
||||
// so it runs after authenticate + accountContext are ready
|
||||
app.register(async (instance) => {
|
||||
instance.get(
|
||||
"/test-account-route",
|
||||
"/accounts/:accountId/test-account-route",
|
||||
{ preHandler: [instance.authenticate, instance.requireAccount] },
|
||||
async (request) => {
|
||||
return { accountId: request.accountId, role: request.membership.role };
|
||||
@ -32,30 +32,15 @@ beforeEach(async () => {
|
||||
});
|
||||
|
||||
describe("requireAccount plugin", () => {
|
||||
it("returns 400 if X-Account-Id header is missing", async () => {
|
||||
const signup = await signupUser(app, "user@example.com", "Org");
|
||||
const { accessToken } = signup.json();
|
||||
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/test-account-route",
|
||||
headers: { authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
expect(res.json().error).toMatch(/X-Account-Id/);
|
||||
});
|
||||
|
||||
it("returns 403 if user is not a member of the account", async () => {
|
||||
const signup = await signupUser(app, "user@example.com", "Org");
|
||||
const { accessToken } = signup.json();
|
||||
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/test-account-route",
|
||||
url: "/accounts/00000000-0000-0000-0000-000000000000/test-account-route",
|
||||
headers: {
|
||||
authorization: `Bearer ${accessToken}`,
|
||||
"x-account-id": "00000000-0000-0000-0000-000000000000",
|
||||
},
|
||||
});
|
||||
|
||||
@ -69,10 +54,9 @@ describe("requireAccount plugin", () => {
|
||||
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/test-account-route",
|
||||
url: `/accounts/${accounts[0].id}/test-account-route`,
|
||||
headers: {
|
||||
authorization: `Bearer ${accessToken}`,
|
||||
"x-account-id": accounts[0].id,
|
||||
},
|
||||
});
|
||||
|
||||
@ -86,7 +70,7 @@ describe("requireAccount plugin", () => {
|
||||
it("returns 401 without auth header", async () => {
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/test-account-route",
|
||||
url: "/accounts/00000000-0000-0000-0000-000000000000/test-account-route",
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
|
||||
@ -21,10 +21,8 @@ export async function cleanDb() {
|
||||
await db.delete(users);
|
||||
}
|
||||
|
||||
/** Request an OTP and return the code from the DB */
|
||||
export async function requestOtpCode(app: FastifyInstance, email: string): Promise<string> {
|
||||
await app.inject({ method: "POST", url: "/login", payload: { email } });
|
||||
|
||||
/** Get the latest OTP code for an email from the DB */
|
||||
async function getLatestOtpCode(email: string): Promise<string> {
|
||||
const [otp] = await db
|
||||
.select()
|
||||
.from(otpCodes)
|
||||
@ -35,17 +33,30 @@ export async function requestOtpCode(app: FastifyInstance, email: string): Promi
|
||||
return otp.code;
|
||||
}
|
||||
|
||||
/** Full signup flow: request OTP → signup → return response */
|
||||
/** Request an OTP via /login (requires existing user) and return the code from the DB */
|
||||
export async function requestOtpCode(app: FastifyInstance, email: string): Promise<string> {
|
||||
await app.inject({ method: "POST", url: "/login", payload: { email } });
|
||||
return getLatestOtpCode(email);
|
||||
}
|
||||
|
||||
/** Request an OTP via /signup and return the code from the DB */
|
||||
export async function requestSignupOtpCode(app: FastifyInstance, email: string): Promise<string> {
|
||||
await app.inject({ method: "POST", url: "/signup", payload: { email } });
|
||||
return getLatestOtpCode(email);
|
||||
}
|
||||
|
||||
/** Full signup flow: request OTP via /signup → verify via /signup/verify → return response */
|
||||
export async function signupUser(
|
||||
app: FastifyInstance,
|
||||
email: string,
|
||||
accountName: string,
|
||||
username?: string,
|
||||
) {
|
||||
const code = await requestOtpCode(app, email);
|
||||
const code = await requestSignupOtpCode(app, email);
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/signup",
|
||||
payload: { email, code, accountName },
|
||||
url: "/signup/verify",
|
||||
payload: { email, code, accountName, username: username ?? email.split("@")[0] },
|
||||
});
|
||||
return res;
|
||||
}
|
||||
@ -55,7 +66,7 @@ export async function loginUser(app: FastifyInstance, email: string) {
|
||||
const code = await requestOtpCode(app, email);
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/sessions",
|
||||
url: "/login/verify",
|
||||
payload: { email, code },
|
||||
});
|
||||
return res;
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { createTestApp, cleanDb } from "./helpers.js";
|
||||
import { createTestApp, cleanDb, signupUser } from "./helpers.js";
|
||||
|
||||
let app: FastifyInstance;
|
||||
|
||||
@ -17,7 +17,9 @@ beforeEach(async () => {
|
||||
});
|
||||
|
||||
describe("POST /login", () => {
|
||||
it("sends OTP for a valid email", async () => {
|
||||
it("sends OTP for an existing user", async () => {
|
||||
await signupUser(app, "test@example.com", "Org");
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/login",
|
||||
@ -28,6 +30,17 @@ describe("POST /login", () => {
|
||||
expect(res.json()).toEqual({ message: "OTP sent to your email" });
|
||||
});
|
||||
|
||||
it("returns 404 if user does not exist", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/login",
|
||||
payload: { email: "unknown@example.com" },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(res.json().error).toMatch(/sign up/i);
|
||||
});
|
||||
|
||||
it("returns 400 if email is missing", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
@ -39,16 +52,16 @@ describe("POST /login", () => {
|
||||
});
|
||||
|
||||
it("rate limits after 3 requests", async () => {
|
||||
const email = "ratelimit@example.com";
|
||||
await signupUser(app, "ratelimit@example.com", "Org");
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await app.inject({ method: "POST", url: "/login", payload: { email } });
|
||||
await app.inject({ method: "POST", url: "/login", payload: { email: "ratelimit@example.com" } });
|
||||
}
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/login",
|
||||
payload: { email },
|
||||
payload: { email: "ratelimit@example.com" },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(429);
|
||||
|
||||
@ -22,18 +22,18 @@ async function setupUser() {
|
||||
return { accessToken, accountId: accounts[0].id as string };
|
||||
}
|
||||
|
||||
function headers(accessToken: string, accountId: string) {
|
||||
return { authorization: `Bearer ${accessToken}`, "x-account-id": accountId };
|
||||
function headers(accessToken: string) {
|
||||
return { authorization: `Bearer ${accessToken}` };
|
||||
}
|
||||
|
||||
describe("GET /projects", () => {
|
||||
describe("GET /accounts/:accountId/projects", () => {
|
||||
it("returns empty list when no projects exist", async () => {
|
||||
const { accessToken, accountId } = await setupUser();
|
||||
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/projects",
|
||||
headers: headers(accessToken, accountId),
|
||||
url: `/accounts/${accountId}/projects`,
|
||||
headers: headers(accessToken),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
@ -45,21 +45,21 @@ describe("GET /projects", () => {
|
||||
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/projects",
|
||||
headers: headers(accessToken, accountId),
|
||||
url: `/accounts/${accountId}/projects`,
|
||||
headers: headers(accessToken),
|
||||
payload: { name: "Project A" },
|
||||
});
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/projects",
|
||||
headers: headers(accessToken, accountId),
|
||||
url: `/accounts/${accountId}/projects`,
|
||||
headers: headers(accessToken),
|
||||
payload: { name: "Project B" },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/projects",
|
||||
headers: headers(accessToken, accountId),
|
||||
url: `/accounts/${accountId}/projects`,
|
||||
headers: headers(accessToken),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
@ -74,8 +74,8 @@ describe("GET /projects", () => {
|
||||
// Create a project in the first account
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/projects",
|
||||
headers: headers(accessToken, accountId),
|
||||
url: `/accounts/${accountId}/projects`,
|
||||
headers: headers(accessToken),
|
||||
payload: { name: "Project A" },
|
||||
});
|
||||
|
||||
@ -91,8 +91,8 @@ describe("GET /projects", () => {
|
||||
// List projects in the second account — should be empty
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: "/projects",
|
||||
headers: headers(accessToken, secondAccountId),
|
||||
url: `/accounts/${secondAccountId}/projects`,
|
||||
headers: headers(accessToken),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
@ -100,19 +100,19 @@ describe("GET /projects", () => {
|
||||
});
|
||||
|
||||
it("returns 401 without auth", async () => {
|
||||
const res = await app.inject({ method: "GET", url: "/projects" });
|
||||
const res = await app.inject({ method: "GET", url: "/accounts/00000000-0000-0000-0000-000000000000/projects" });
|
||||
expect(res.statusCode).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /projects", () => {
|
||||
describe("POST /accounts/:accountId/projects", () => {
|
||||
it("creates a project", async () => {
|
||||
const { accessToken, accountId } = await setupUser();
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/projects",
|
||||
headers: headers(accessToken, accountId),
|
||||
url: `/accounts/${accountId}/projects`,
|
||||
headers: headers(accessToken),
|
||||
payload: { name: "New Project" },
|
||||
});
|
||||
|
||||
@ -128,8 +128,8 @@ describe("POST /projects", () => {
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/projects",
|
||||
headers: headers(accessToken, accountId),
|
||||
url: `/accounts/${accountId}/projects`,
|
||||
headers: headers(accessToken),
|
||||
payload: {},
|
||||
});
|
||||
|
||||
@ -137,22 +137,22 @@ describe("POST /projects", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /projects/:id", () => {
|
||||
describe("PATCH /accounts/:accountId/projects/:id", () => {
|
||||
it("updates a project name", async () => {
|
||||
const { accessToken, accountId } = await setupUser();
|
||||
|
||||
const create = await app.inject({
|
||||
method: "POST",
|
||||
url: "/projects",
|
||||
headers: headers(accessToken, accountId),
|
||||
url: `/accounts/${accountId}/projects`,
|
||||
headers: headers(accessToken),
|
||||
payload: { name: "Old Name" },
|
||||
});
|
||||
const projectId = create.json().id;
|
||||
|
||||
const res = await app.inject({
|
||||
method: "PATCH",
|
||||
url: `/projects/${projectId}`,
|
||||
headers: headers(accessToken, accountId),
|
||||
url: `/accounts/${accountId}/projects/${projectId}`,
|
||||
headers: headers(accessToken),
|
||||
payload: { name: "New Name" },
|
||||
});
|
||||
|
||||
@ -166,8 +166,8 @@ describe("PATCH /projects/:id", () => {
|
||||
|
||||
const res = await app.inject({
|
||||
method: "PATCH",
|
||||
url: "/projects/00000000-0000-0000-0000-000000000000",
|
||||
headers: headers(accessToken, accountId),
|
||||
url: `/accounts/${accountId}/projects/00000000-0000-0000-0000-000000000000`,
|
||||
headers: headers(accessToken),
|
||||
payload: { name: "Nope" },
|
||||
});
|
||||
|
||||
@ -179,8 +179,8 @@ describe("PATCH /projects/:id", () => {
|
||||
|
||||
const create = await app.inject({
|
||||
method: "POST",
|
||||
url: "/projects",
|
||||
headers: headers(accessToken, accountId),
|
||||
url: `/accounts/${accountId}/projects`,
|
||||
headers: headers(accessToken),
|
||||
payload: { name: "Project" },
|
||||
});
|
||||
const projectId = create.json().id;
|
||||
@ -197,8 +197,8 @@ describe("PATCH /projects/:id", () => {
|
||||
// Try to update from second account
|
||||
const res = await app.inject({
|
||||
method: "PATCH",
|
||||
url: `/projects/${projectId}`,
|
||||
headers: headers(accessToken, secondAccountId),
|
||||
url: `/accounts/${secondAccountId}/projects/${projectId}`,
|
||||
headers: headers(accessToken),
|
||||
payload: { name: "Hacked" },
|
||||
});
|
||||
|
||||
@ -206,22 +206,22 @@ describe("PATCH /projects/:id", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /projects/:id", () => {
|
||||
describe("DELETE /accounts/:accountId/projects/:id", () => {
|
||||
it("deletes a project", async () => {
|
||||
const { accessToken, accountId } = await setupUser();
|
||||
|
||||
const create = await app.inject({
|
||||
method: "POST",
|
||||
url: "/projects",
|
||||
headers: headers(accessToken, accountId),
|
||||
url: `/accounts/${accountId}/projects`,
|
||||
headers: headers(accessToken),
|
||||
payload: { name: "To Delete" },
|
||||
});
|
||||
const projectId = create.json().id;
|
||||
|
||||
const res = await app.inject({
|
||||
method: "DELETE",
|
||||
url: `/projects/${projectId}`,
|
||||
headers: headers(accessToken, accountId),
|
||||
url: `/accounts/${accountId}/projects/${projectId}`,
|
||||
headers: headers(accessToken),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(204);
|
||||
@ -229,8 +229,8 @@ describe("DELETE /projects/:id", () => {
|
||||
// Verify it's gone
|
||||
const list = await app.inject({
|
||||
method: "GET",
|
||||
url: "/projects",
|
||||
headers: headers(accessToken, accountId),
|
||||
url: `/accounts/${accountId}/projects`,
|
||||
headers: headers(accessToken),
|
||||
});
|
||||
expect(list.json()).toEqual([]);
|
||||
});
|
||||
@ -240,8 +240,8 @@ describe("DELETE /projects/:id", () => {
|
||||
|
||||
const res = await app.inject({
|
||||
method: "DELETE",
|
||||
url: "/projects/00000000-0000-0000-0000-000000000000",
|
||||
headers: headers(accessToken, accountId),
|
||||
url: `/accounts/${accountId}/projects/00000000-0000-0000-0000-000000000000`,
|
||||
headers: headers(accessToken),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
@ -252,8 +252,8 @@ describe("DELETE /projects/:id", () => {
|
||||
|
||||
const create = await app.inject({
|
||||
method: "POST",
|
||||
url: "/projects",
|
||||
headers: headers(accessToken, accountId),
|
||||
url: `/accounts/${accountId}/projects`,
|
||||
headers: headers(accessToken),
|
||||
payload: { name: "Project" },
|
||||
});
|
||||
const projectId = create.json().id;
|
||||
@ -270,8 +270,8 @@ describe("DELETE /projects/:id", () => {
|
||||
// Try to delete from second account
|
||||
const res = await app.inject({
|
||||
method: "DELETE",
|
||||
url: `/projects/${projectId}`,
|
||||
headers: headers(accessToken, secondAccountId),
|
||||
url: `/accounts/${secondAccountId}/projects/${projectId}`,
|
||||
headers: headers(accessToken),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
|
||||
@ -3,7 +3,7 @@ import type { FastifyInstance } from "fastify";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { db } from "../src/db/index.js";
|
||||
import { sessions } from "../src/db/schema.js";
|
||||
import { createTestApp, cleanDb, requestOtpCode, signupUser, loginUser } from "./helpers.js";
|
||||
import { createTestApp, cleanDb, requestSignupOtpCode, signupUser, loginUser } from "./helpers.js";
|
||||
|
||||
let app: FastifyInstance;
|
||||
|
||||
@ -19,7 +19,7 @@ beforeEach(async () => {
|
||||
await cleanDb();
|
||||
});
|
||||
|
||||
describe("POST /sessions", () => {
|
||||
describe("POST /login/verify", () => {
|
||||
it("creates session for existing user", async () => {
|
||||
await signupUser(app, "user@example.com", "Org");
|
||||
|
||||
@ -34,10 +34,11 @@ describe("POST /sessions", () => {
|
||||
});
|
||||
|
||||
it("returns 401 for unknown email", async () => {
|
||||
const code = await requestOtpCode(app, "unknown@example.com");
|
||||
// Request OTP via signup flow (user doesn't exist yet)
|
||||
const code = await requestSignupOtpCode(app, "unknown@example.com");
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/sessions",
|
||||
url: "/login/verify",
|
||||
payload: { email: "unknown@example.com", code },
|
||||
});
|
||||
|
||||
@ -48,7 +49,7 @@ describe("POST /sessions", () => {
|
||||
it("returns 400 with invalid OTP", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/sessions",
|
||||
url: "/login/verify",
|
||||
payload: { email: "user@example.com", code: "000000" },
|
||||
});
|
||||
|
||||
@ -58,7 +59,7 @@ describe("POST /sessions", () => {
|
||||
it("returns 400 if email or code is missing", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/sessions",
|
||||
url: "/login/verify",
|
||||
payload: { email: "user@example.com" },
|
||||
});
|
||||
|
||||
@ -67,14 +68,14 @@ describe("POST /sessions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /sessions/refresh", () => {
|
||||
describe("POST /refresh", () => {
|
||||
it("rotates tokens and returns user + accounts", async () => {
|
||||
const signup = await signupUser(app, "user@example.com", "Org");
|
||||
const { refreshToken } = signup.json();
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/sessions/refresh",
|
||||
url: "/refresh",
|
||||
payload: { refreshToken },
|
||||
});
|
||||
const body = res.json();
|
||||
@ -89,7 +90,7 @@ describe("POST /sessions/refresh", () => {
|
||||
it("returns 400 if refreshToken is missing", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/sessions/refresh",
|
||||
url: "/refresh",
|
||||
payload: {},
|
||||
});
|
||||
|
||||
@ -100,7 +101,7 @@ describe("POST /sessions/refresh", () => {
|
||||
it("returns 401 for invalid refresh token", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/sessions/refresh",
|
||||
url: "/refresh",
|
||||
payload: { refreshToken: "invalid-token" },
|
||||
});
|
||||
|
||||
@ -119,7 +120,7 @@ describe("POST /sessions/refresh", () => {
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/sessions/refresh",
|
||||
url: "/refresh",
|
||||
payload: { refreshToken },
|
||||
});
|
||||
|
||||
@ -134,14 +135,14 @@ describe("POST /sessions/refresh", () => {
|
||||
// First refresh succeeds
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: "/sessions/refresh",
|
||||
url: "/refresh",
|
||||
payload: { refreshToken },
|
||||
});
|
||||
|
||||
// Reuse the old token — should be detected as theft
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/sessions/refresh",
|
||||
url: "/refresh",
|
||||
payload: { refreshToken },
|
||||
});
|
||||
|
||||
@ -150,14 +151,14 @@ describe("POST /sessions/refresh", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /sessions/logout", () => {
|
||||
describe("POST /logout", () => {
|
||||
it("revokes all sessions", async () => {
|
||||
const signup = await signupUser(app, "user@example.com", "Org");
|
||||
const { accessToken, refreshToken } = signup.json();
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/sessions/logout",
|
||||
url: "/logout",
|
||||
headers: { authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
|
||||
@ -166,7 +167,7 @@ describe("POST /sessions/logout", () => {
|
||||
// Refresh token should no longer work
|
||||
const refresh = await app.inject({
|
||||
method: "POST",
|
||||
url: "/sessions/refresh",
|
||||
url: "/refresh",
|
||||
payload: { refreshToken },
|
||||
});
|
||||
|
||||
@ -176,7 +177,7 @@ describe("POST /sessions/logout", () => {
|
||||
it("returns 401 without auth header", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/sessions/logout",
|
||||
url: "/logout",
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(401);
|
||||
|
||||
@ -3,3 +3,14 @@ process.env.DATABASE_URL = "postgresql://fedjens@localhost:5432/eyrun_test";
|
||||
process.env.JWT_SECRET = "test-secret-that-is-at-least-32-characters-long";
|
||||
process.env.PORT = "0";
|
||||
process.env.HOST = "127.0.0.1";
|
||||
process.env.RESEND_API_KEY = "re_test_fake_key";
|
||||
|
||||
// Mock Resend so tests don't send real emails
|
||||
import { vi } from "vitest";
|
||||
vi.mock("resend", () => ({
|
||||
Resend: class {
|
||||
emails = {
|
||||
send: vi.fn().mockResolvedValue({ data: { id: "test" }, error: null }),
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { describe, it, expect, beforeAll, afterAll, beforeEach } from "vitest";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import { createTestApp, cleanDb, requestOtpCode, signupUser } from "./helpers.js";
|
||||
import { createTestApp, cleanDb, requestSignupOtpCode, signupUser } from "./helpers.js";
|
||||
|
||||
let app: FastifyInstance;
|
||||
|
||||
@ -17,14 +17,50 @@ beforeEach(async () => {
|
||||
});
|
||||
|
||||
describe("POST /signup", () => {
|
||||
it("sends OTP for a new email", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/signup",
|
||||
payload: { email: "new@example.com" },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json()).toEqual({ message: "OTP sent to your email" });
|
||||
});
|
||||
|
||||
it("returns 409 if user already exists", async () => {
|
||||
await signupUser(app, "existing@example.com", "Org");
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/signup",
|
||||
payload: { email: "existing@example.com" },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(409);
|
||||
});
|
||||
|
||||
it("returns 400 if email is missing", async () => {
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/signup",
|
||||
payload: {},
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /signup/verify", () => {
|
||||
it("creates user, account, and returns tokens", async () => {
|
||||
const res = await signupUser(app, "new@example.com", "My Org");
|
||||
const res = await signupUser(app, "new@example.com", "My Org", "newuser");
|
||||
const body = res.json();
|
||||
|
||||
expect(res.statusCode).toBe(201);
|
||||
expect(body.accessToken).toBeDefined();
|
||||
expect(body.refreshToken).toBeDefined();
|
||||
expect(body.user.email).toBe("new@example.com");
|
||||
expect(body.user.name).toBe("newuser");
|
||||
expect(body.accounts).toHaveLength(1);
|
||||
expect(body.accounts[0].name).toBe("My Org");
|
||||
expect(body.accounts[0].role).toBe("owner");
|
||||
@ -33,33 +69,36 @@ describe("POST /signup", () => {
|
||||
it("returns 409 if user already exists", async () => {
|
||||
await signupUser(app, "existing@example.com", "Org 1");
|
||||
|
||||
const code = await requestOtpCode(app, "existing@example.com");
|
||||
const code = await requestSignupOtpCode(app, "new2@example.com");
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/signup",
|
||||
payload: { email: "existing@example.com", code, accountName: "Org 2" },
|
||||
url: "/signup/verify",
|
||||
payload: { email: "existing@example.com", code, accountName: "Org 2", username: "test" },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(409);
|
||||
// OTP was for a different email, so it will fail with 400
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 with invalid OTP code", async () => {
|
||||
await requestOtpCode(app, "test@example.com");
|
||||
await requestSignupOtpCode(app, "test@example.com");
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/signup",
|
||||
payload: { email: "test@example.com", code: "000000", accountName: "Org" },
|
||||
url: "/signup/verify",
|
||||
payload: { email: "test@example.com", code: "000000", accountName: "Org", username: "test" },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it("returns 400 if fields are missing", async () => {
|
||||
it("returns 400 if required fields are missing", async () => {
|
||||
const code = await requestSignupOtpCode(app, "test@example.com");
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: "/signup",
|
||||
payload: { email: "test@example.com" },
|
||||
url: "/signup/verify",
|
||||
payload: { email: "test@example.com", code },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(400);
|
||||
|
||||
@ -25,8 +25,8 @@ async function setupUserWithProject() {
|
||||
|
||||
const projectRes = await app.inject({
|
||||
method: "POST",
|
||||
url: "/projects",
|
||||
headers: { authorization: `Bearer ${accessToken}`, "x-account-id": accountId },
|
||||
url: `/accounts/${accountId}/projects`,
|
||||
headers: { authorization: `Bearer ${accessToken}` },
|
||||
payload: { name: "My Project" },
|
||||
});
|
||||
const projectId = projectRes.json().id as string;
|
||||
@ -34,8 +34,8 @@ async function setupUserWithProject() {
|
||||
return { accessToken, accountId, projectId };
|
||||
}
|
||||
|
||||
function headers(accessToken: string, accountId: string) {
|
||||
return { authorization: `Bearer ${accessToken}`, "x-account-id": accountId };
|
||||
function headers(accessToken: string) {
|
||||
return { authorization: `Bearer ${accessToken}` };
|
||||
}
|
||||
|
||||
async function seedSmallPackage() {
|
||||
@ -46,14 +46,14 @@ async function seedSmallPackage() {
|
||||
return pkg;
|
||||
}
|
||||
|
||||
describe("GET /projects/:projectId/wms", () => {
|
||||
describe("GET /accounts/:accountId/projects/:projectId/wms", () => {
|
||||
it("returns empty list when no WMs exist", async () => {
|
||||
const { accessToken, accountId, projectId } = await setupUserWithProject();
|
||||
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: `/projects/${projectId}/wms`,
|
||||
headers: headers(accessToken, accountId),
|
||||
url: `/accounts/${accountId}/projects/${projectId}/wms`,
|
||||
headers: headers(accessToken),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
@ -65,21 +65,21 @@ describe("GET /projects/:projectId/wms", () => {
|
||||
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: `/projects/${projectId}/wms`,
|
||||
headers: headers(accessToken, accountId),
|
||||
url: `/accounts/${accountId}/projects/${projectId}/wms`,
|
||||
headers: headers(accessToken),
|
||||
payload: { name: "WM 1", vcpu: 1, ram: 512, disk: 10 },
|
||||
});
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: `/projects/${projectId}/wms`,
|
||||
headers: headers(accessToken, accountId),
|
||||
url: `/accounts/${accountId}/projects/${projectId}/wms`,
|
||||
headers: headers(accessToken),
|
||||
payload: { name: "WM 2", vcpu: 2, ram: 1024, disk: 20 },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: `/projects/${projectId}/wms`,
|
||||
headers: headers(accessToken, accountId),
|
||||
url: `/accounts/${accountId}/projects/${projectId}/wms`,
|
||||
headers: headers(accessToken),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
@ -100,23 +100,23 @@ describe("GET /projects/:projectId/wms", () => {
|
||||
|
||||
const res = await app.inject({
|
||||
method: "GET",
|
||||
url: `/projects/${projectId}/wms`,
|
||||
headers: headers(accessToken, secondAccountId),
|
||||
url: `/accounts/${secondAccountId}/projects/${projectId}/wms`,
|
||||
headers: headers(accessToken),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /projects/:projectId/wms", () => {
|
||||
describe("POST /accounts/:accountId/projects/:projectId/wms", () => {
|
||||
it("creates a WM from a package", async () => {
|
||||
const { accessToken, accountId, projectId } = await setupUserWithProject();
|
||||
const pkg = await seedSmallPackage();
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: `/projects/${projectId}/wms`,
|
||||
headers: headers(accessToken, accountId),
|
||||
url: `/accounts/${accountId}/projects/${projectId}/wms`,
|
||||
headers: headers(accessToken),
|
||||
payload: { name: "My WM", packageId: pkg.id },
|
||||
});
|
||||
|
||||
@ -134,8 +134,8 @@ describe("POST /projects/:projectId/wms", () => {
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: `/projects/${projectId}/wms`,
|
||||
headers: headers(accessToken, accountId),
|
||||
url: `/accounts/${accountId}/projects/${projectId}/wms`,
|
||||
headers: headers(accessToken),
|
||||
payload: { name: "Custom WM", vcpu: 4, ram: 8192, disk: 100 },
|
||||
});
|
||||
|
||||
@ -152,8 +152,8 @@ describe("POST /projects/:projectId/wms", () => {
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: `/projects/${projectId}/wms`,
|
||||
headers: headers(accessToken, accountId),
|
||||
url: `/accounts/${accountId}/projects/${projectId}/wms`,
|
||||
headers: headers(accessToken),
|
||||
payload: { name: "Bad WM", vcpu: 2 },
|
||||
});
|
||||
|
||||
@ -166,8 +166,8 @@ describe("POST /projects/:projectId/wms", () => {
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: `/projects/${projectId}/wms`,
|
||||
headers: headers(accessToken, accountId),
|
||||
url: `/accounts/${accountId}/projects/${projectId}/wms`,
|
||||
headers: headers(accessToken),
|
||||
payload: { name: "WM", packageId: "00000000-0000-0000-0000-000000000000" },
|
||||
});
|
||||
|
||||
@ -188,8 +188,8 @@ describe("POST /projects/:projectId/wms", () => {
|
||||
|
||||
const res = await app.inject({
|
||||
method: "POST",
|
||||
url: `/projects/${projectId}/wms`,
|
||||
headers: headers(accessToken, secondAccountId),
|
||||
url: `/accounts/${secondAccountId}/projects/${projectId}/wms`,
|
||||
headers: headers(accessToken),
|
||||
payload: { name: "WM", vcpu: 1, ram: 512, disk: 10 },
|
||||
});
|
||||
|
||||
@ -197,22 +197,22 @@ describe("POST /projects/:projectId/wms", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("PATCH /projects/:projectId/wms/:id", () => {
|
||||
describe("PATCH /accounts/:accountId/projects/:projectId/wms/:id", () => {
|
||||
it("updates WM name", async () => {
|
||||
const { accessToken, accountId, projectId } = await setupUserWithProject();
|
||||
|
||||
const create = await app.inject({
|
||||
method: "POST",
|
||||
url: `/projects/${projectId}/wms`,
|
||||
headers: headers(accessToken, accountId),
|
||||
url: `/accounts/${accountId}/projects/${projectId}/wms`,
|
||||
headers: headers(accessToken),
|
||||
payload: { name: "Old Name", vcpu: 1, ram: 512, disk: 10 },
|
||||
});
|
||||
const wmId = create.json().id;
|
||||
|
||||
const res = await app.inject({
|
||||
method: "PATCH",
|
||||
url: `/projects/${projectId}/wms/${wmId}`,
|
||||
headers: headers(accessToken, accountId),
|
||||
url: `/accounts/${accountId}/projects/${projectId}/wms/${wmId}`,
|
||||
headers: headers(accessToken),
|
||||
payload: { name: "New Name" },
|
||||
});
|
||||
|
||||
@ -227,16 +227,16 @@ describe("PATCH /projects/:projectId/wms/:id", () => {
|
||||
|
||||
const create = await app.inject({
|
||||
method: "POST",
|
||||
url: `/projects/${projectId}/wms`,
|
||||
headers: headers(accessToken, accountId),
|
||||
url: `/accounts/${accountId}/projects/${projectId}/wms`,
|
||||
headers: headers(accessToken),
|
||||
payload: { name: "WM", packageId: pkg.id },
|
||||
});
|
||||
const wmId = create.json().id;
|
||||
|
||||
const res = await app.inject({
|
||||
method: "PATCH",
|
||||
url: `/projects/${projectId}/wms/${wmId}`,
|
||||
headers: headers(accessToken, accountId),
|
||||
url: `/accounts/${accountId}/projects/${projectId}/wms/${wmId}`,
|
||||
headers: headers(accessToken),
|
||||
payload: { disk: 40 },
|
||||
});
|
||||
|
||||
@ -251,8 +251,8 @@ describe("PATCH /projects/:projectId/wms/:id", () => {
|
||||
|
||||
const res = await app.inject({
|
||||
method: "PATCH",
|
||||
url: `/projects/${projectId}/wms/00000000-0000-0000-0000-000000000000`,
|
||||
headers: headers(accessToken, accountId),
|
||||
url: `/accounts/${accountId}/projects/${projectId}/wms/00000000-0000-0000-0000-000000000000`,
|
||||
headers: headers(accessToken),
|
||||
payload: { name: "Nope" },
|
||||
});
|
||||
|
||||
@ -264,8 +264,8 @@ describe("PATCH /projects/:projectId/wms/:id", () => {
|
||||
|
||||
const create = await app.inject({
|
||||
method: "POST",
|
||||
url: `/projects/${projectId}/wms`,
|
||||
headers: headers(accessToken, accountId),
|
||||
url: `/accounts/${accountId}/projects/${projectId}/wms`,
|
||||
headers: headers(accessToken),
|
||||
payload: { name: "WM", vcpu: 1, ram: 512, disk: 10 },
|
||||
});
|
||||
const wmId = create.json().id;
|
||||
@ -280,8 +280,8 @@ describe("PATCH /projects/:projectId/wms/:id", () => {
|
||||
|
||||
const res = await app.inject({
|
||||
method: "PATCH",
|
||||
url: `/projects/${projectId}/wms/${wmId}`,
|
||||
headers: headers(accessToken, secondAccountId),
|
||||
url: `/accounts/${secondAccountId}/projects/${projectId}/wms/${wmId}`,
|
||||
headers: headers(accessToken),
|
||||
payload: { name: "Hacked" },
|
||||
});
|
||||
|
||||
@ -289,22 +289,22 @@ describe("PATCH /projects/:projectId/wms/:id", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("DELETE /projects/:projectId/wms/:id", () => {
|
||||
describe("DELETE /accounts/:accountId/projects/:projectId/wms/:id", () => {
|
||||
it("deletes a WM", async () => {
|
||||
const { accessToken, accountId, projectId } = await setupUserWithProject();
|
||||
|
||||
const create = await app.inject({
|
||||
method: "POST",
|
||||
url: `/projects/${projectId}/wms`,
|
||||
headers: headers(accessToken, accountId),
|
||||
url: `/accounts/${accountId}/projects/${projectId}/wms`,
|
||||
headers: headers(accessToken),
|
||||
payload: { name: "To Delete", vcpu: 1, ram: 512, disk: 10 },
|
||||
});
|
||||
const wmId = create.json().id;
|
||||
|
||||
const res = await app.inject({
|
||||
method: "DELETE",
|
||||
url: `/projects/${projectId}/wms/${wmId}`,
|
||||
headers: headers(accessToken, accountId),
|
||||
url: `/accounts/${accountId}/projects/${projectId}/wms/${wmId}`,
|
||||
headers: headers(accessToken),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(204);
|
||||
@ -312,8 +312,8 @@ describe("DELETE /projects/:projectId/wms/:id", () => {
|
||||
// Verify it's gone
|
||||
const list = await app.inject({
|
||||
method: "GET",
|
||||
url: `/projects/${projectId}/wms`,
|
||||
headers: headers(accessToken, accountId),
|
||||
url: `/accounts/${accountId}/projects/${projectId}/wms`,
|
||||
headers: headers(accessToken),
|
||||
});
|
||||
expect(list.json()).toEqual([]);
|
||||
});
|
||||
@ -323,8 +323,8 @@ describe("DELETE /projects/:projectId/wms/:id", () => {
|
||||
|
||||
const res = await app.inject({
|
||||
method: "DELETE",
|
||||
url: `/projects/${projectId}/wms/00000000-0000-0000-0000-000000000000`,
|
||||
headers: headers(accessToken, accountId),
|
||||
url: `/accounts/${accountId}/projects/${projectId}/wms/00000000-0000-0000-0000-000000000000`,
|
||||
headers: headers(accessToken),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
@ -335,8 +335,8 @@ describe("DELETE /projects/:projectId/wms/:id", () => {
|
||||
|
||||
const create = await app.inject({
|
||||
method: "POST",
|
||||
url: `/projects/${projectId}/wms`,
|
||||
headers: headers(accessToken, accountId),
|
||||
url: `/accounts/${accountId}/projects/${projectId}/wms`,
|
||||
headers: headers(accessToken),
|
||||
payload: { name: "WM", vcpu: 1, ram: 512, disk: 10 },
|
||||
});
|
||||
const wmId = create.json().id;
|
||||
@ -351,8 +351,8 @@ describe("DELETE /projects/:projectId/wms/:id", () => {
|
||||
|
||||
const res = await app.inject({
|
||||
method: "DELETE",
|
||||
url: `/projects/${projectId}/wms/${wmId}`,
|
||||
headers: headers(accessToken, secondAccountId),
|
||||
url: `/accounts/${secondAccountId}/projects/${projectId}/wms/${wmId}`,
|
||||
headers: headers(accessToken),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(404);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user