diff --git a/package.json b/package.json
index 21ed673..b9d314a 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f273284..3e7175d 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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: {}
diff --git a/src/app.ts b/src/app.ts
index 0eae2fa..6e7504e 100644
--- a/src/app.ts
+++ b/src/app.ts
@@ -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);
diff --git a/src/plugins/scalar-docs.ts b/src/plugins/scalar-docs.ts
new file mode 100644
index 0000000..5583e09
--- /dev/null
+++ b/src/plugins/scalar-docs.ts
@@ -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(`
+
+
+
+ ${title}
+
+
+
+
+
+
+
+
+ `);
+ });
+
+ // Keep /docs as an alias for convenience
+ app.get("/docs", async (request, reply) => {
+ return reply.redirect("/");
+ });
+ },
+ {
+ name: "scalar-docs",
+ },
+);
diff --git a/src/routes/accounts.ts b/src/routes/accounts.ts
index 163ba53..2d85a51 100644
--- a/src/routes/accounts.ts
+++ b/src/routes/accounts.ts
@@ -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) => {
diff --git a/src/routes/health.ts b/src/routes/health.ts
index befaf7b..b5ac958 100644
--- a/src/routes/health.ts
+++ b/src/routes/health.ts
@@ -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" };
+ },
+ );
}
diff --git a/src/routes/login.ts b/src/routes/login.ts
index 8265c8a..66153d3 100644
--- a/src/routes/login.ts
+++ b/src/routes/login.ts
@@ -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) => {
diff --git a/src/routes/me.ts b/src/routes/me.ts
index 3a633af..c3bfe33 100644
--- a/src/routes/me.ts
+++ b/src/routes/me.ts
@@ -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) => {
diff --git a/src/routes/sessions.ts b/src/routes/sessions.ts
index 5f72e3d..94e18ef 100644
--- a/src/routes/sessions.ts
+++ b/src/routes/sessions.ts
@@ -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();
+ },
+ );
}
diff --git a/src/routes/signup.ts b/src/routes/signup.ts
index ba28ab9..5202fa6 100644
--- a/src/routes/signup.ts
+++ b/src/routes/signup.ts
@@ -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) => {