feat: add API key authentication to user and resource endpoints

Update multiple endpoints to use authenticateRequest() middleware
instead of session-only authentication. This allows integration tests
to authenticate using API keys.

Endpoints updated:
- /api/user/profile
- /api/user/likes/tools, collections, agents
- /api/agents (list, create)
- /api/agents/[id] (get, update, delete)
- /api/collections (list, create)
- /api/collections/[id] (get, update, delete)
This commit is contained in:
Ajax Davis 2026-01-14 01:21:58 +10:00
parent 72ff35b333
commit b6d507fcf5
8 changed files with 71 additions and 93 deletions

View file

@ -1,9 +1,9 @@
import { Prisma, prisma } from '@tpmjs/db';
import { UpdateAgentSchema } from '@tpmjs/types/agent';
import { headers } from 'next/headers';
import type { NextRequest } from 'next/server';
import { logActivity } from '~/lib/activity';
import { authenticateRequest } from '~/lib/api-keys/middleware';
import {
apiConflict,
apiForbidden,
@ -13,7 +13,6 @@ import {
apiUnauthorized,
apiValidationError,
} from '~/lib/api-response';
import { auth } from '~/lib/auth';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
@ -30,7 +29,7 @@ export async function GET(_request: NextRequest, context: RouteContext) {
const requestId = crypto.randomUUID();
try {
const session = await auth.api.getSession({ headers: await headers() });
const authResult = await authenticateRequest();
const { id } = await context.params;
const agent = await prisma.agent.findUnique({
@ -88,7 +87,7 @@ export async function GET(_request: NextRequest, context: RouteContext) {
}
// Check access - owner or public
const isOwner = session?.user?.id === agent.userId;
const isOwner = authResult.userId === agent.userId;
if (!isOwner && !agent.isPublic) {
return apiForbidden('Access denied', requestId);
}
@ -135,8 +134,8 @@ export async function PATCH(request: NextRequest, context: RouteContext) {
const requestId = crypto.randomUUID();
try {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
const authResult = await authenticateRequest();
if (!authResult.authenticated || !authResult.userId) {
return apiUnauthorized('Authentication required', requestId);
}
@ -159,7 +158,7 @@ export async function PATCH(request: NextRequest, context: RouteContext) {
if (!existing) {
return apiNotFound('Agent', requestId);
}
if (existing.userId !== session.user.id) {
if (existing.userId !== authResult.userId) {
return apiForbidden('Access denied', requestId);
}
@ -176,7 +175,7 @@ export async function PATCH(request: NextRequest, context: RouteContext) {
// Check name uniqueness if being changed
if (parsed.data.name) {
const existingByName = await prisma.agent.findFirst({
where: { userId: session.user.id, name: parsed.data.name, id: { not: id } },
where: { userId: authResult.userId, name: parsed.data.name, id: { not: id } },
});
if (existingByName) {
return apiConflict('An agent with this name already exists', requestId);
@ -210,7 +209,7 @@ export async function PATCH(request: NextRequest, context: RouteContext) {
// Log activity (fire-and-forget)
logActivity({
userId: session.user.id,
userId: authResult.userId,
type: 'AGENT_UPDATED',
targetName: agent.name,
targetType: 'agent',
@ -240,8 +239,8 @@ export async function DELETE(_request: NextRequest, context: RouteContext) {
const requestId = crypto.randomUUID();
try {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
const authResult = await authenticateRequest();
if (!authResult.authenticated || !authResult.userId) {
return apiUnauthorized('Authentication required', requestId);
}
@ -255,7 +254,7 @@ export async function DELETE(_request: NextRequest, context: RouteContext) {
if (!existing) {
return apiNotFound('Agent', requestId);
}
if (existing.userId !== session.user.id) {
if (existing.userId !== authResult.userId) {
return apiForbidden('Access denied', requestId);
}
@ -263,7 +262,7 @@ export async function DELETE(_request: NextRequest, context: RouteContext) {
// Log activity (fire-and-forget) - note: agentId is not included since agent is deleted
logActivity({
userId: session.user.id,
userId: authResult.userId,
type: 'AGENT_DELETED',
targetName: existing.name,
targetType: 'agent',

View file

@ -1,10 +1,9 @@
import { prisma } from '@tpmjs/db';
import { AGENT_LIMITS, CreateAgentSchema } from '@tpmjs/types/agent';
import { headers } from 'next/headers';
import { type NextRequest, NextResponse } from 'next/server';
import { logActivity } from '~/lib/activity';
import { auth } from '~/lib/auth';
import { authenticateRequest } from '~/lib/api-keys/middleware';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
@ -28,8 +27,8 @@ function generateUid(name: string): string {
*/
export async function GET(request: NextRequest): Promise<NextResponse> {
try {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
const authResult = await authenticateRequest();
if (!authResult.authenticated || !authResult.userId) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 });
}
@ -38,7 +37,7 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
const offset = Number.parseInt(searchParams.get('offset') || '0');
const agents = await prisma.agent.findMany({
where: { userId: session.user.id },
where: { userId: authResult.userId },
select: {
id: true,
uid: true,
@ -94,10 +93,11 @@ export async function GET(request: NextRequest): Promise<NextResponse> {
*/
export async function POST(request: NextRequest): Promise<NextResponse> {
try {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
const authResult = await authenticateRequest();
if (!authResult.authenticated || !authResult.userId) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 });
}
const userId = authResult.userId;
const body = await request.json();
const parsed = CreateAgentSchema.safeParse(body);
@ -110,7 +110,7 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
// Check agent limit
const agentCount = await prisma.agent.count({
where: { userId: session.user.id },
where: { userId },
});
if (agentCount >= AGENT_LIMITS.MAX_AGENTS_PER_USER) {
return NextResponse.json(
@ -146,7 +146,7 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
// Check for name uniqueness within user's agents
const existingByName = await prisma.agent.findFirst({
where: { userId: session.user.id, name },
where: { userId, name },
});
if (existingByName) {
return NextResponse.json(
@ -159,7 +159,7 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
const agent = await prisma.$transaction(async (tx) => {
const newAgent = await tx.agent.create({
data: {
userId: session.user.id,
userId,
uid: finalUid,
name,
description,
@ -214,7 +214,7 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
// Auto-like the agent
await tx.agentLike.create({
data: {
userId: session.user.id,
userId,
agentId: newAgent.id,
},
});
@ -224,7 +224,7 @@ export async function POST(request: NextRequest): Promise<NextResponse> {
// Log activity (fire-and-forget)
logActivity({
userId: session.user.id,
userId,
type: 'AGENT_CREATED',
targetName: agent.name,
targetType: 'agent',

View file

@ -1,9 +1,8 @@
import { Prisma, prisma } from '@tpmjs/db';
import { UpdateCollectionSchema } from '@tpmjs/types/collection';
import { headers } from 'next/headers';
import { type NextRequest, NextResponse } from 'next/server';
import { logActivity } from '~/lib/activity';
import { auth } from '~/lib/auth';
import { authenticateRequest } from '~/lib/api-keys/middleware';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
@ -55,11 +54,9 @@ export async function GET(
try {
// Check authentication
const session = await auth.api.getSession({
headers: await headers(),
});
const authResult = await authenticateRequest();
if (!session) {
if (!authResult.authenticated || !authResult.userId) {
return NextResponse.json(
{
success: false,
@ -113,7 +110,7 @@ export async function GET(
}
// Check ownership (unless collection is public)
if (collection.userId !== session.user.id && !collection.isPublic) {
if (collection.userId !== authResult.userId && !collection.isPublic) {
return NextResponse.json(
{
success: false,
@ -139,7 +136,7 @@ export async function GET(
toolCount: collection._count.tools,
createdAt: collection.createdAt,
updatedAt: collection.updatedAt,
isOwner: collection.userId === session.user.id,
isOwner: collection.userId === authResult.userId,
user: {
username: collection.user.username,
},
@ -192,11 +189,9 @@ export async function PATCH(
try {
// Check authentication
const session = await auth.api.getSession({
headers: await headers(),
});
const authResult = await authenticateRequest();
if (!session) {
if (!authResult.authenticated || !authResult.userId) {
return NextResponse.json(
{
success: false,
@ -223,7 +218,7 @@ export async function PATCH(
);
}
if (existingCollection.userId !== session.user.id) {
if (existingCollection.userId !== authResult.userId) {
return NextResponse.json(
{
success: false,
@ -259,7 +254,7 @@ export async function PATCH(
if (name && name !== existingCollection.name) {
const duplicateName = await prisma.collection.findFirst({
where: {
userId: session.user.id,
userId: authResult.userId,
name: { equals: name, mode: 'insensitive' },
id: { not: id },
},
@ -302,7 +297,7 @@ export async function PATCH(
// Log activity (fire-and-forget)
logActivity({
userId: session.user.id,
userId: authResult.userId,
type: 'COLLECTION_UPDATED',
targetName: collection.name,
targetType: 'collection',
@ -350,11 +345,9 @@ export async function DELETE(
try {
// Check authentication
const session = await auth.api.getSession({
headers: await headers(),
});
const authResult = await authenticateRequest();
if (!session) {
if (!authResult.authenticated || !authResult.userId) {
return NextResponse.json(
{
success: false,
@ -381,7 +374,7 @@ export async function DELETE(
);
}
if (collection.userId !== session.user.id) {
if (collection.userId !== authResult.userId) {
return NextResponse.json(
{
success: false,
@ -402,7 +395,7 @@ export async function DELETE(
// Log activity (fire-and-forget) - note: collectionId not included since it's deleted
logActivity({
userId: session.user.id,
userId: authResult.userId,
type: 'COLLECTION_DELETED',
targetName: collectionName,
targetType: 'collection',

View file

@ -1,9 +1,8 @@
import { prisma } from '@tpmjs/db';
import { COLLECTION_LIMITS, CreateCollectionSchema } from '@tpmjs/types/collection';
import { headers } from 'next/headers';
import { type NextRequest, NextResponse } from 'next/server';
import { logActivity } from '~/lib/activity';
import { auth } from '~/lib/auth';
import { authenticateRequest } from '~/lib/api-keys/middleware';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
@ -89,11 +88,9 @@ export async function GET(request: NextRequest): Promise<NextResponse<ApiRespons
try {
// Check authentication
const session = await auth.api.getSession({
headers: await headers(),
});
const authResult = await authenticateRequest();
if (!session) {
if (!authResult.authenticated || !authResult.userId) {
return NextResponse.json(
{
success: false,
@ -113,7 +110,7 @@ export async function GET(request: NextRequest): Promise<NextResponse<ApiRespons
// Fetch collections with tool count
const collections = await prisma.collection.findMany({
where: {
userId: session.user.id,
userId: authResult.userId,
...(search && {
OR: [
{ name: { contains: search, mode: 'insensitive' } },
@ -169,11 +166,9 @@ export async function POST(request: NextRequest): Promise<NextResponse<ApiRespon
try {
// Check authentication
const session = await auth.api.getSession({
headers: await headers(),
});
const authResult = await authenticateRequest();
if (!session) {
if (!authResult.authenticated || !authResult.userId) {
return NextResponse.json(
{
success: false,
@ -183,6 +178,7 @@ export async function POST(request: NextRequest): Promise<NextResponse<ApiRespon
{ status: 401 }
);
}
const userId = authResult.userId;
// Parse and validate request body
const body = await request.json();
@ -207,7 +203,7 @@ export async function POST(request: NextRequest): Promise<NextResponse<ApiRespon
// Check collection limit
const existingCount = await prisma.collection.count({
where: { userId: session.user.id },
where: { userId },
});
if (existingCount >= COLLECTION_LIMITS.MAX_COLLECTIONS_PER_USER) {
@ -227,7 +223,7 @@ export async function POST(request: NextRequest): Promise<NextResponse<ApiRespon
// Check for duplicate name (case-insensitive)
const existingCollection = await prisma.collection.findFirst({
where: {
userId: session.user.id,
userId,
name: { equals: name, mode: 'insensitive' },
},
});
@ -247,13 +243,13 @@ export async function POST(request: NextRequest): Promise<NextResponse<ApiRespon
}
// Generate unique slug for the collection
const slug = await generateUniqueSlug(session.user.id, name);
const slug = await generateUniqueSlug(userId, name);
// Create collection with auto-like (user likes their own collection)
const collection = await prisma.$transaction(async (tx) => {
const newCollection = await tx.collection.create({
data: {
userId: session.user.id,
userId,
name,
slug,
description: description || null,
@ -265,7 +261,7 @@ export async function POST(request: NextRequest): Promise<NextResponse<ApiRespon
// Auto-like the collection
await tx.collectionLike.create({
data: {
userId: session.user.id,
userId,
collectionId: newCollection.id,
},
});
@ -275,7 +271,7 @@ export async function POST(request: NextRequest): Promise<NextResponse<ApiRespon
// Log activity (fire-and-forget)
logActivity({
userId: session.user.id,
userId,
type: 'COLLECTION_CREATED',
targetName: collection.name,
targetType: 'collection',

View file

@ -1,7 +1,6 @@
import { prisma } from '@tpmjs/db';
import { headers } from 'next/headers';
import { type NextRequest, NextResponse } from 'next/server';
import { auth } from '~/lib/auth';
import { authenticateRequest } from '~/lib/api-keys/middleware';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
@ -32,11 +31,9 @@ export async function GET(request: NextRequest): Promise<NextResponse<ApiRespons
const requestId = crypto.randomUUID();
try {
const session = await auth.api.getSession({
headers: await headers(),
});
const authResult = await authenticateRequest();
if (!session) {
if (!authResult.authenticated || !authResult.userId) {
return NextResponse.json(
{
success: false,
@ -52,7 +49,7 @@ export async function GET(request: NextRequest): Promise<NextResponse<ApiRespons
const offset = Math.max(Number.parseInt(searchParams.get('offset') || '0', 10), 0);
const likes = await prisma.agentLike.findMany({
where: { userId: session.user.id },
where: { userId: authResult.userId },
include: {
agent: {
include: {

View file

@ -1,7 +1,6 @@
import { prisma } from '@tpmjs/db';
import { headers } from 'next/headers';
import { type NextRequest, NextResponse } from 'next/server';
import { auth } from '~/lib/auth';
import { authenticateRequest } from '~/lib/api-keys/middleware';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
@ -32,11 +31,9 @@ export async function GET(request: NextRequest): Promise<NextResponse<ApiRespons
const requestId = crypto.randomUUID();
try {
const session = await auth.api.getSession({
headers: await headers(),
});
const authResult = await authenticateRequest();
if (!session) {
if (!authResult.authenticated || !authResult.userId) {
return NextResponse.json(
{
success: false,
@ -52,7 +49,7 @@ export async function GET(request: NextRequest): Promise<NextResponse<ApiRespons
const offset = Math.max(Number.parseInt(searchParams.get('offset') || '0', 10), 0);
const likes = await prisma.collectionLike.findMany({
where: { userId: session.user.id },
where: { userId: authResult.userId },
include: {
collection: {
include: {

View file

@ -1,7 +1,6 @@
import { prisma } from '@tpmjs/db';
import { headers } from 'next/headers';
import { type NextRequest, NextResponse } from 'next/server';
import { auth } from '~/lib/auth';
import { authenticateRequest } from '~/lib/api-keys/middleware';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
@ -32,11 +31,9 @@ export async function GET(request: NextRequest): Promise<NextResponse<ApiRespons
const requestId = crypto.randomUUID();
try {
const session = await auth.api.getSession({
headers: await headers(),
});
const authResult = await authenticateRequest();
if (!session) {
if (!authResult.authenticated || !authResult.userId) {
return NextResponse.json(
{
success: false,
@ -52,7 +49,7 @@ export async function GET(request: NextRequest): Promise<NextResponse<ApiRespons
const offset = Math.max(Number.parseInt(searchParams.get('offset') || '0', 10), 0);
const likes = await prisma.toolLike.findMany({
where: { userId: session.user.id },
where: { userId: authResult.userId },
include: {
tool: {
include: {

View file

@ -1,9 +1,8 @@
import { prisma } from '@tpmjs/db';
import { RESERVED_USERNAMES, USERNAME_REGEX, UpdateUserProfileSchema } from '@tpmjs/types/user';
import { headers } from 'next/headers';
import { type NextRequest, NextResponse } from 'next/server';
import { auth } from '~/lib/auth';
import { authenticateRequest } from '~/lib/api-keys/middleware';
export const runtime = 'nodejs';
export const dynamic = 'force-dynamic';
@ -14,13 +13,13 @@ export const dynamic = 'force-dynamic';
*/
export async function GET(): Promise<NextResponse> {
try {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
const authResult = await authenticateRequest();
if (!authResult.authenticated || !authResult.userId) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 });
}
const user = await prisma.user.findUnique({
where: { id: session.user.id },
where: { id: authResult.userId },
select: {
id: true,
name: true,
@ -51,8 +50,8 @@ export async function GET(): Promise<NextResponse> {
*/
export async function PATCH(request: NextRequest): Promise<NextResponse> {
try {
const session = await auth.api.getSession({ headers: await headers() });
if (!session?.user?.id) {
const authResult = await authenticateRequest();
if (!authResult.authenticated || !authResult.userId) {
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 });
}
@ -100,7 +99,7 @@ export async function PATCH(request: NextRequest): Promise<NextResponse> {
const existingUser = await prisma.user.findFirst({
where: {
username,
NOT: { id: session.user.id },
NOT: { id: authResult.userId },
},
select: { id: true },
});
@ -117,7 +116,7 @@ export async function PATCH(request: NextRequest): Promise<NextResponse> {
}
const updatedUser = await prisma.user.update({
where: { id: session.user.id },
where: { id: authResult.userId },
data: {
...(name !== undefined && { name }),
...(username !== undefined && { username }),