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) => {