feat(api): add Better Auth plugin and protected GET /me endpoint

- authPlugin runs preHandler that turns Fastify request headers into a Headers
  object, calls auth.api.getSession, and decorates request.user / request.session
- requireAuth() preHandler short-circuits with 401 when not signed in
- New module modules/me with GET /me returning the authenticated user/session
- env validates BETTER_AUTH_SECRET (≥32) and BETTER_AUTH_URL — must match web
- Restored .js extensions in shared packages so NodeNext-resolution consumers
  (api) typecheck cleanly; Next webpack now uses extensionAlias to map .js → .ts
- Re-enabled NodeNext for packages/auth and packages/db tsconfigs

Verified end-to-end:
- POST /api/auth/sign-in/email on web returns session cookie
- GET /me on api with the cookie returns 200 + user/session
- GET /me without the cookie returns 401

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
lucianoandClaude Opus 4.7 committed 2026-04-29 13:50:14 +02:00
1 parent 0139b13fc6
commit 5f17f95d6d
12 files changed
+81 -8

No files matched your search

+8
View File
@@ -7,3 +7,11 @@ LOG_LEVEL=info
CORS_ORIGINS=http://localhost:3000
DATABASE_URL=postgresql://luciano:luciano%40@localhost:5432/ketopath?schema=public
# Better Auth — DEVE coincidere con apps/web/.env (sessione condivisa)
BETTER_AUTH_SECRET=
BETTER_AUTH_URL=http://localhost:3000
# Google OAuth (configurato a fine progetto)
# GOOGLE_CLIENT_ID=
# GOOGLE_CLIENT_SECRET=
+2
View File
@@ -15,8 +15,10 @@
"@fastify/helmet": "^11.1.1",
"@fastify/rate-limit": "^9.1.0",
"@fastify/sensible": "^5.6.0",
"@ketopath/auth": "workspace:*",
"@ketopath/db": "workspace:*",
"@ketopath/shared": "workspace:*",
"better-auth": "^1.0.21",
"fastify": "^4.28.1",
"fastify-plugin": "^4.5.1",
"zod": "^3.23.8"
+4
View File
@@ -7,6 +7,8 @@ import Fastify, { type FastifyInstance } from 'fastify';
import { env } from './config/env.js';
import { dbRoutes } from './modules/db/db.routes.js';
import { healthRoutes } from './modules/health/health.routes.js';
import { meRoutes } from './modules/me/me.routes.js';
import { authPlugin } from './plugins/auth.js';
import { prismaPlugin } from './plugins/prisma.js';
export async function buildApp(): Promise<FastifyInstance> {
@@ -36,9 +38,11 @@ export async function buildApp(): Promise<FastifyInstance> {
});
await app.register(sensible);
await app.register(prismaPlugin);
await app.register(authPlugin);
await app.register(healthRoutes);
await app.register(dbRoutes);
await app.register(meRoutes);
return app;
}
+2
View File
@@ -15,6 +15,8 @@ const envSchema = z.object({
.filter(Boolean),
),
DATABASE_URL: z.string().url(),
BETTER_AUTH_SECRET: z.string().min(32),
BETTER_AUTH_URL: z.string().url(),
});
export type Env = z.infer<typeof envSchema>;
+10
View File
@@ -0,0 +1,10 @@
import type { FastifyPluginAsync } from 'fastify';
import { requireAuth } from '../../plugins/auth.js';
export const meRoutes: FastifyPluginAsync = async (fastify) => {
fastify.get('/me', { preHandler: requireAuth() }, async (request) => ({
user: request.user,
session: request.session,
}));
};
+39
View File
@@ -0,0 +1,39 @@
import { auth } from '@ketopath/auth';
import type { FastifyReply, FastifyRequest } from 'fastify';
import fp from 'fastify-plugin';
type SessionPayload = Awaited<ReturnType<typeof auth.api.getSession>>;
type User = NonNullable<SessionPayload>['user'];
type Session = NonNullable<SessionPayload>['session'];
declare module 'fastify' {
interface FastifyRequest {
user: User | null;
session: Session | null;
}
}
export const authPlugin = fp(async (app) => {
app.decorateRequest('user', null);
app.decorateRequest('session', null);
app.addHook('preHandler', async (request) => {
const headers = new Headers();
for (const [key, value] of Object.entries(request.headers)) {
if (typeof value === 'string') headers.set(key, value);
else if (Array.isArray(value)) headers.set(key, value.join(', '));
}
const result = await auth.api.getSession({ headers });
request.user = result?.user ?? null;
request.session = result?.session ?? null;
});
});
export function requireAuth() {
return async (request: FastifyRequest, reply: FastifyReply): Promise<void> => {
if (!request.user || !request.session) {
return reply.code(401).send({ error: 'unauthorized' });
}
};
}
+10 -1
View File
@@ -5,10 +5,19 @@ const withNextIntl = createNextIntlPlugin('./src/i18n.ts');
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
transpilePackages: ['@ketopath/auth', '@ketopath/shared', '@ketopath/ui'],
transpilePackages: ['@ketopath/auth', '@ketopath/db', '@ketopath/shared', '@ketopath/ui'],
experimental: {
typedRoutes: true,
},
webpack(config) {
// I package interni usano .js nelle import (NodeNext). Webpack non lo
// risolve di default per i file TS: gli diciamo di provare anche .ts/.tsx.
config.resolve.extensionAlias = {
...config.resolve.extensionAlias,
'.js': ['.ts', '.tsx', '.js', '.jsx'],
};
return config;
},
};
export default withNextIntl(nextConfig);
+2 -2
View File
@@ -1,2 +1,2 @@
export { auth, type Auth } from './server';
export { readAuthEnv, type AuthEnv } from './env';
export { auth, type Auth } from './server.js';
export { readAuthEnv, type AuthEnv } from './env.js';
+1 -1
View File
@@ -2,7 +2,7 @@ import { prisma } from '@ketopath/db';
import { betterAuth } from 'better-auth';
import { prismaAdapter } from 'better-auth/adapters/prisma';
import { readAuthEnv } from './env';
import { readAuthEnv } from './env.js';
const env = readAuthEnv();
+2 -3
View File
@@ -1,11 +1,10 @@
{
"extends": "@ketopath/tsconfig/base.json",
"extends": "@ketopath/tsconfig/node.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"composite": true,
"lib": ["DOM", "ES2022"],
"types": ["node"]
"lib": ["DOM", "ES2022"]
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
+1 -1
View File
@@ -1,4 +1,4 @@
export { prisma } from './client';
export { prisma } from './client.js';
export {
ActivityLevel,
CookingTime,
BIN
View File
Binary file not shown.