better api

This commit is contained in:
Fredrik Jensen 2026-02-08 13:39:33 +01:00
parent 1bfd12ceb2
commit 654b243ab8
21 changed files with 695 additions and 272 deletions

View File

@ -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:*)"
]
}
}

View File

@ -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

View File

@ -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
View File

@ -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

View File

@ -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);

View File

@ -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. */

View File

@ -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

View File

@ -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" });
}

View File

@ -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 };
},
);
}

View File

@ -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" },
},
},

View File

@ -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",
{

View File

@ -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 });
},
);
}

View File

@ -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" },
},

View File

@ -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);

View File

@ -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;

View File

@ -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);

View File

@ -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);

View File

@ -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);

View File

@ -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 }),
};
},
}));

View File

@ -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);

View File

@ -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);