This commit is contained in:
Fredrik Jensen 2026-02-07 18:35:57 +01:00
parent 127b19b4e1
commit 708d23df22
10 changed files with 466 additions and 33 deletions

View File

@ -18,6 +18,7 @@
"license": "ISC",
"packageManager": "pnpm@10.18.3",
"dependencies": {
"@fastify/swagger": "^9.7.0",
"dotenv": "^17.2.4",
"drizzle-orm": "^0.45.1",
"fastify": "^5.7.4",

61
pnpm-lock.yaml generated
View File

@ -8,6 +8,9 @@ importers:
.:
dependencies:
'@fastify/swagger':
specifier: ^9.7.0
version: 9.7.0
dotenv:
specifier: ^17.2.4
version: 17.2.4
@ -38,7 +41,7 @@ importers:
version: 25.2.1
'@vitest/coverage-v8':
specifier: ^4.0.18
version: 4.0.18(vitest@4.0.18(@types/node@25.2.1)(tsx@4.21.0))
version: 4.0.18(vitest@4.0.18(@types/node@25.2.1)(tsx@4.21.0)(yaml@2.8.2))
drizzle-kit:
specifier: ^0.31.8
version: 0.31.8
@ -50,7 +53,7 @@ importers:
version: 5.9.3
vitest:
specifier: ^4.0.18
version: 4.0.18(@types/node@25.2.1)(tsx@4.21.0)
version: 4.0.18(@types/node@25.2.1)(tsx@4.21.0)(yaml@2.8.2)
packages:
@ -548,6 +551,9 @@ packages:
'@fastify/proxy-addr@5.1.0':
resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==}
'@fastify/swagger@9.7.0':
resolution: {integrity: sha512-Vp1SC1GC2Hrkd3faFILv86BzUNyFz5N4/xdExqtCgkGASOzn/x+eMe4qXIGq7cdT6wif/P/oa6r1Ruqx19paZA==}
'@jridgewell/resolve-uri@3.1.2':
resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==}
engines: {node: '>=6.0.0'}
@ -1007,6 +1013,10 @@ packages:
json-schema-ref-resolver@3.0.0:
resolution: {integrity: sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==}
json-schema-resolver@3.0.0:
resolution: {integrity: sha512-HqMnbz0tz2DaEJ3ntsqtx3ezzZyDE7G56A/pPY/NGmrPu76UzsWquOpHFRAf5beTNXoH2LU5cQePVvRli1nchA==}
engines: {node: '>=20'}
json-schema-traverse@1.0.0:
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
@ -1069,6 +1079,9 @@ packages:
resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==}
engines: {node: '>=14.0.0'}
openapi-types@12.1.3:
resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
@ -1300,6 +1313,11 @@ packages:
engines: {node: '>=8'}
hasBin: true
yaml@2.8.2:
resolution: {integrity: sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==}
engines: {node: '>= 14.6'}
hasBin: true
zod@4.3.6:
resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==}
@ -1577,6 +1595,16 @@ snapshots:
'@fastify/forwarded': 3.0.1
ipaddr.js: 2.3.0
'@fastify/swagger@9.7.0':
dependencies:
fastify-plugin: 5.1.0
json-schema-resolver: 3.0.0
openapi-types: 12.1.3
rfdc: 1.4.1
yaml: 2.8.2
transitivePeerDependencies:
- supports-color
'@jridgewell/resolve-uri@3.1.2': {}
'@jridgewell/sourcemap-codec@1.5.5': {}
@ -1685,7 +1713,7 @@ snapshots:
dependencies:
undici-types: 7.16.0
'@vitest/coverage-v8@4.0.18(vitest@4.0.18(@types/node@25.2.1)(tsx@4.21.0))':
'@vitest/coverage-v8@4.0.18(vitest@4.0.18(@types/node@25.2.1)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@bcoe/v8-coverage': 1.0.2
'@vitest/utils': 4.0.18
@ -1697,7 +1725,7 @@ snapshots:
obug: 2.1.1
std-env: 3.10.0
tinyrainbow: 3.0.3
vitest: 4.0.18(@types/node@25.2.1)(tsx@4.21.0)
vitest: 4.0.18(@types/node@25.2.1)(tsx@4.21.0)(yaml@2.8.2)
'@vitest/expect@4.0.18':
dependencies:
@ -1708,13 +1736,13 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.0.3
'@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.2.1)(tsx@4.21.0))':
'@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.2.1)(tsx@4.21.0)(yaml@2.8.2))':
dependencies:
'@vitest/spy': 4.0.18
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 7.3.1(@types/node@25.2.1)(tsx@4.21.0)
vite: 7.3.1(@types/node@25.2.1)(tsx@4.21.0)(yaml@2.8.2)
'@vitest/pretty-format@4.0.18':
dependencies:
@ -1982,6 +2010,14 @@ snapshots:
dependencies:
dequal: 2.0.3
json-schema-resolver@3.0.0:
dependencies:
debug: 4.4.3
fast-uri: 3.1.0
rfdc: 1.4.1
transitivePeerDependencies:
- supports-color
json-schema-traverse@1.0.0: {}
jsonwebtoken@9.0.3:
@ -2050,6 +2086,8 @@ snapshots:
on-exit-leak-free@2.1.2: {}
openapi-types@12.1.3: {}
pathe@2.0.3: {}
picocolors@1.1.1: {}
@ -2200,7 +2238,7 @@ snapshots:
undici-types@7.16.0: {}
vite@7.3.1(@types/node@25.2.1)(tsx@4.21.0):
vite@7.3.1(@types/node@25.2.1)(tsx@4.21.0)(yaml@2.8.2):
dependencies:
esbuild: 0.27.3
fdir: 6.5.0(picomatch@4.0.3)
@ -2212,11 +2250,12 @@ snapshots:
'@types/node': 25.2.1
fsevents: 2.3.3
tsx: 4.21.0
yaml: 2.8.2
vitest@4.0.18(@types/node@25.2.1)(tsx@4.21.0):
vitest@4.0.18(@types/node@25.2.1)(tsx@4.21.0)(yaml@2.8.2):
dependencies:
'@vitest/expect': 4.0.18
'@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.2.1)(tsx@4.21.0))
'@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.2.1)(tsx@4.21.0)(yaml@2.8.2))
'@vitest/pretty-format': 4.0.18
'@vitest/runner': 4.0.18
'@vitest/snapshot': 4.0.18
@ -2233,7 +2272,7 @@ snapshots:
tinyexec: 1.0.2
tinyglobby: 0.2.15
tinyrainbow: 3.0.3
vite: 7.3.1(@types/node@25.2.1)(tsx@4.21.0)
vite: 7.3.1(@types/node@25.2.1)(tsx@4.21.0)(yaml@2.8.2)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 25.2.1
@ -2255,4 +2294,6 @@ snapshots:
siginfo: 2.0.0
stackback: 0.0.2
yaml@2.8.2: {}
zod@4.3.6: {}

View File

@ -1,8 +1,10 @@
import Fastify from "fastify";
import fastifySwagger from "@fastify/swagger";
import { client } from "./db/index.js";
import { errorHandler } from "./plugins/error-handler.js";
import { authenticate } from "./plugins/authenticate.js";
import { accountContext } from "./plugins/account-context.js";
import scalarDocs from "./plugins/scalar-docs.js";
import { registerRoutes } from "./routes/index.js";
export function buildApp() {
@ -14,6 +16,34 @@ export function buildApp() {
await client.end();
});
app.register(fastifySwagger, {
openapi: {
openapi: "3.0.0",
info: {
title: "Eyrun API",
description: "Authentication and account management API",
version: "1.0.0",
},
servers: [
{
url: "http://localhost:3000",
description: "Development server",
},
],
components: {
securitySchemes: {
bearerAuth: {
type: "http",
scheme: "bearer",
bearerFormat: "JWT",
},
},
},
},
});
app.register(scalarDocs);
app.register(errorHandler);
app.register(authenticate);
app.register(accountContext);

View File

@ -0,0 +1,46 @@
import fastifyPlugin from "fastify-plugin";
import type { FastifyInstance } from "fastify";
export default fastifyPlugin(
async function (app: FastifyInstance) {
// Expose OpenAPI spec at /docs/json
app.get("/docs/json", async () => {
return app.swagger();
});
// Scalar UI at root
app.get("/", async (request, reply) => {
const title = "Eyrun API";
return reply.type("text/html").send(`
<!DOCTYPE html>
<html>
<head>
<title>${title}</title>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style>
body {
margin: 0;
padding: 0;
}
</style>
</head>
<body>
<script
id="api-reference"
data-url="/docs/json"
src="https://cdn.jsdelivr.net/npm/@scalar/api-reference"></script>
</body>
</html>
`);
});
// Keep /docs as an alias for convenience
app.get("/docs", async (request, reply) => {
return reply.redirect("/");
});
},
{
name: "scalar-docs",
},
);

View File

@ -5,9 +5,41 @@ import { getUserAccounts } from "../lib/accounts.js";
export async function accountRoutes(app: FastifyInstance) {
// GET /accounts — list user's accounts with role
app.get("/accounts", { preHandler: [app.authenticate] }, async (request) => {
return getUserAccounts(request.user.sub);
});
app.get(
"/accounts",
{
preHandler: [app.authenticate],
schema: {
description: "List all accounts for the current user",
tags: ["Accounts"],
security: [{ bearerAuth: [] }],
response: {
200: {
description: "List of user accounts",
type: "array",
items: {
type: "object",
properties: {
id: { type: "string" },
name: { type: "string" },
role: { type: "string" },
},
},
},
401: {
description: "Unauthorized",
type: "object",
properties: {
error: { type: "string" },
},
},
},
},
},
async (request) => {
return getUserAccounts(request.user.sub);
},
);
// POST /accounts — create a new account (user becomes owner)
app.post<{ Body: { name: string } }>(
@ -15,6 +47,9 @@ export async function accountRoutes(app: FastifyInstance) {
{
preHandler: [app.authenticate],
schema: {
description: "Create a new account",
tags: ["Accounts"],
security: [{ bearerAuth: [] }],
body: {
type: "object",
required: ["name"],
@ -22,6 +57,24 @@ export async function accountRoutes(app: FastifyInstance) {
name: { type: "string", minLength: 1 },
},
},
response: {
201: {
description: "Account created successfully",
type: "object",
properties: {
id: { type: "string" },
name: { type: "string" },
role: { type: "string" },
},
},
401: {
description: "Unauthorized",
type: "object",
properties: {
error: { type: "string" },
},
},
},
},
},
async (request, reply) => {

View File

@ -1,7 +1,25 @@
import type { FastifyInstance } from "fastify";
export async function healthRoutes(app: FastifyInstance) {
app.get("/health", async () => {
return { status: "ok" };
});
app.get(
"/health",
{
schema: {
description: "Health check endpoint",
tags: ["Health"],
response: {
200: {
description: "Service is healthy",
type: "object",
properties: {
status: { type: "string" },
},
},
},
},
},
async () => {
return { status: "ok" };
},
);
}

View File

@ -7,6 +7,8 @@ export async function loginRoutes(app: FastifyInstance) {
"/login",
{
schema: {
description: "Request an OTP (One-Time Password) for login",
tags: ["Authentication"],
body: {
type: "object",
required: ["email"],
@ -14,6 +16,22 @@ export async function loginRoutes(app: FastifyInstance) {
email: { type: "string", format: "email" },
},
},
response: {
200: {
description: "OTP sent successfully",
type: "object",
properties: {
message: { type: "string" },
},
},
429: {
description: "Too many OTP requests",
type: "object",
properties: {
error: { type: "string" },
},
},
},
},
},
async (request, reply) => {

View File

@ -6,18 +6,65 @@ import { getUserAccounts } from "../lib/accounts.js";
export async function meRoutes(app: FastifyInstance) {
// GET /me — return current user with accounts (authenticated)
app.get("/me", { preHandler: [app.authenticate] }, async (request, reply) => {
const userId = request.user.sub;
app.get(
"/me",
{
preHandler: [app.authenticate],
schema: {
description: "Get current authenticated user profile and accounts",
tags: ["Users"],
security: [{ bearerAuth: [] }],
response: {
200: {
description: "Current user with accounts",
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" },
},
},
},
},
},
401: {
description: "Unauthorized",
type: "object",
properties: {
error: { type: "string" },
},
},
404: {
description: "User not found",
type: "object",
properties: {
error: { type: "string" },
},
},
},
},
},
async (request, reply) => {
const userId = request.user.sub;
const [user] = await db.select().from(users).where(eq(users.id, userId));
if (!user) {
return reply.status(404).send({ error: "User not found" });
}
const [user] = await db.select().from(users).where(eq(users.id, userId));
if (!user) {
return reply.status(404).send({ error: "User not found" });
}
const userAccounts = await getUserAccounts(userId);
const userAccounts = await getUserAccounts(userId);
return { ...user, accounts: userAccounts };
});
return { ...user, accounts: userAccounts };
},
);
// PATCH /me — update current user's profile (authenticated)
app.patch<{ Body: { name: string } }>(
@ -25,6 +72,9 @@ export async function meRoutes(app: FastifyInstance) {
{
preHandler: [app.authenticate],
schema: {
description: "Update current user's profile",
tags: ["Users"],
security: [{ bearerAuth: [] }],
body: {
type: "object",
required: ["name"],
@ -32,6 +82,31 @@ export async function meRoutes(app: FastifyInstance) {
name: { type: "string", minLength: 1 },
},
},
response: {
200: {
description: "User updated successfully",
type: "object",
properties: {
id: { type: "string" },
email: { type: "string" },
name: { type: "string" },
},
},
401: {
description: "Unauthorized",
type: "object",
properties: {
error: { type: "string" },
},
},
404: {
description: "User not found",
type: "object",
properties: {
error: { type: "string" },
},
},
},
},
},
async (request, reply) => {

View File

@ -12,6 +12,8 @@ export async function sessionRoutes(app: FastifyInstance) {
"/",
{
schema: {
description: "Create a new session for an existing user with OTP verification",
tags: ["Authentication"],
body: {
type: "object",
required: ["email", "code"],
@ -20,6 +22,49 @@ export async function sessionRoutes(app: FastifyInstance) {
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) => {
@ -51,6 +96,8 @@ export async function sessionRoutes(app: FastifyInstance) {
"/refresh",
{
schema: {
description: "Refresh access token using a refresh token",
tags: ["Authentication"],
body: {
type: "object",
required: ["refreshToken"],
@ -58,6 +105,42 @@ export async function sessionRoutes(app: FastifyInstance) {
refreshToken: { type: "string", minLength: 1 },
},
},
response: {
200: {
description: "New tokens generated 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" },
},
},
},
},
},
401: {
description: "Invalid, expired, or reused refresh token",
type: "object",
properties: {
error: { type: "string" },
},
},
},
},
},
async (request, reply) => {
@ -102,14 +185,37 @@ export async function sessionRoutes(app: FastifyInstance) {
);
// POST /sessions/logout — revoke all sessions (authenticated)
app.post("/logout", { preHandler: [app.authenticate] }, async (request, reply) => {
const userId = request.user.sub;
app.post(
"/logout",
{
preHandler: [app.authenticate],
schema: {
description: "Revoke all sessions for the current user (logout)",
tags: ["Authentication"],
security: [{ bearerAuth: [] }],
response: {
204: {
description: "Successfully logged out",
},
401: {
description: "Unauthorized",
type: "object",
properties: {
error: { type: "string" },
},
},
},
},
},
async (request, reply) => {
const userId = request.user.sub;
await db
.update(sessions)
.set({ revokedAt: new Date() })
.where(and(eq(sessions.userId, userId), isNull(sessions.revokedAt)));
await db
.update(sessions)
.set({ revokedAt: new Date() })
.where(and(eq(sessions.userId, userId), isNull(sessions.revokedAt)));
return reply.status(204).send();
});
return reply.status(204).send();
},
);
}

View File

@ -12,6 +12,8 @@ export async function signupRoutes(app: FastifyInstance) {
"/signup",
{
schema: {
description: "Sign up a new user with OTP verification",
tags: ["Authentication"],
body: {
type: "object",
required: ["email", "code", "accountName"],
@ -21,6 +23,49 @@ export async function signupRoutes(app: FastifyInstance) {
accountName: { type: "string", minLength: 1 },
},
},
response: {
201: {
description: "User 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" },
},
},
409: {
description: "User already exists",
type: "object",
properties: {
error: { type: "string" },
},
},
},
},
},
async (request, reply) => {