diff --git a/apps/api/package.json b/apps/api/package.json index 82ad2c65..69eceb40 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -8,6 +8,7 @@ "start": "dotenv -e ../../.env node dist/index.js", "build": "rm -rf dist && tsdown", "gen:bots": "jiti scripts/get-bots.ts && biome format --write src/bots/bots.ts", + "test:manage": "jiti scripts/test-manage-api.ts", "typecheck": "tsc --noEmit" }, "dependencies": { diff --git a/apps/api/scripts/test-manage-api.ts b/apps/api/scripts/test-manage-api.ts new file mode 100644 index 00000000..77b6a8f8 --- /dev/null +++ b/apps/api/scripts/test-manage-api.ts @@ -0,0 +1,340 @@ +/** + * One-off script to test all /manage/ API endpoints + * + * Usage: + * pnpm test:manage + * or + * pnpm jiti scripts/test-manage-api.ts + * + * Set API_URL environment variable to test against a different server: + * API_URL=http://localhost:3000 pnpm test:manage + */ + +const CLIENT_ID = process.env.CLIENT_ID!; +const CLIENT_SECRET = process.env.CLIENT_SECRET!; +const API_BASE_URL = process.env.API_URL || 'http://localhost:3333'; + +if (!CLIENT_ID || !CLIENT_SECRET) { + console.error('CLIENT_ID and CLIENT_SECRET must be set'); + process.exit(1); +} + +interface TestResult { + name: string; + method: string; + url: string; + status: number; + success: boolean; + error?: string; + data?: any; +} + +const results: TestResult[] = []; + +async function makeRequest( + method: string, + path: string, + body?: any, +): Promise { + const url = `${API_BASE_URL}${path}`; + const headers: Record = { + 'openpanel-client-id': CLIENT_ID, + 'openpanel-client-secret': CLIENT_SECRET, + }; + + // Only set Content-Type if there's a body + if (body) { + headers['Content-Type'] = 'application/json'; + } + + try { + const response = await fetch(url, { + method, + headers, + body: body ? JSON.stringify(body) : undefined, + }); + + const data = await response.json().catch(() => ({})); + + return { + name: `${method} ${path}`, + method, + url, + status: response.status, + success: response.ok, + error: response.ok ? undefined : data.message || 'Request failed', + data: response.ok ? data : undefined, + }; + } catch (error) { + return { + name: `${method} ${path}`, + method, + url, + status: 0, + success: false, + error: error instanceof Error ? error.message : 'Unknown error', + }; + } +} + +async function testProjects() { + console.log('\nšŸ“ Testing Projects endpoints...\n'); + + // Create project + const createResult = await makeRequest('POST', '/manage/projects', { + name: `Test Project ${Date.now()}`, + domain: 'https://example.com', + cors: ['https://example.com', 'https://www.example.com'], + crossDomain: false, + types: ['website'], + }); + results.push(createResult); + console.log( + `āœ“ POST /manage/projects: ${createResult.success ? 'āœ…' : 'āŒ'} ${createResult.status}`, + ); + if (createResult.error) console.log(` Error: ${createResult.error}`); + + const projectId = createResult.data?.data?.id; + const clientId = createResult.data?.data?.client?.id; + const clientSecret = createResult.data?.data?.client?.secret; + + if (projectId) { + console.log(` Created project: ${projectId}`); + if (clientId) console.log(` Created client: ${clientId}`); + if (clientSecret) console.log(` Client secret: ${clientSecret}`); + } + + // List projects + const listResult = await makeRequest('GET', '/manage/projects'); + results.push(listResult); + console.log( + `āœ“ GET /manage/projects: ${listResult.success ? 'āœ…' : 'āŒ'} ${listResult.status}`, + ); + if (listResult.data?.data?.length) { + console.log(` Found ${listResult.data.data.length} projects`); + } + + if (projectId) { + // Get project + const getResult = await makeRequest('GET', `/manage/projects/${projectId}`); + results.push(getResult); + console.log( + `āœ“ GET /manage/projects/:id: ${getResult.success ? 'āœ…' : 'āŒ'} ${getResult.status}`, + ); + + // Update project + const updateResult = await makeRequest( + 'PATCH', + `/manage/projects/${projectId}`, + { + name: 'Updated Test Project', + crossDomain: true, + }, + ); + results.push(updateResult); + console.log( + `āœ“ PATCH /manage/projects/:id: ${updateResult.success ? 'āœ…' : 'āŒ'} ${updateResult.status}`, + ); + + // Delete project (soft delete) + const deleteResult = await makeRequest( + 'DELETE', + `/manage/projects/${projectId}`, + ); + results.push(deleteResult); + console.log( + `āœ“ DELETE /manage/projects/:id: ${deleteResult.success ? 'āœ…' : 'āŒ'} ${deleteResult.status}`, + ); + } + + return { projectId, clientId }; +} + +async function testClients(projectId?: string) { + console.log('\nšŸ”‘ Testing Clients endpoints...\n'); + + // Create client + const createResult = await makeRequest('POST', '/manage/clients', { + name: `Test Client ${Date.now()}`, + projectId: projectId || undefined, + type: 'read', + }); + results.push(createResult); + console.log( + `āœ“ POST /manage/clients: ${createResult.success ? 'āœ…' : 'āŒ'} ${createResult.status}`, + ); + if (createResult.error) console.log(` Error: ${createResult.error}`); + + const clientId = createResult.data?.data?.id; + const clientSecret = createResult.data?.data?.secret; + + if (clientId) { + console.log(` Created client: ${clientId}`); + if (clientSecret) console.log(` Client secret: ${clientSecret}`); + } + + // List clients + const listResult = await makeRequest( + 'GET', + projectId ? `/manage/clients?projectId=${projectId}` : '/manage/clients', + ); + results.push(listResult); + console.log( + `āœ“ GET /manage/clients: ${listResult.success ? 'āœ…' : 'āŒ'} ${listResult.status}`, + ); + if (listResult.data?.data?.length) { + console.log(` Found ${listResult.data.data.length} clients`); + } + + if (clientId) { + // Get client + const getResult = await makeRequest('GET', `/manage/clients/${clientId}`); + results.push(getResult); + console.log( + `āœ“ GET /manage/clients/:id: ${getResult.success ? 'āœ…' : 'āŒ'} ${getResult.status}`, + ); + + // Update client + const updateResult = await makeRequest( + 'PATCH', + `/manage/clients/${clientId}`, + { + name: 'Updated Test Client', + }, + ); + results.push(updateResult); + console.log( + `āœ“ PATCH /manage/clients/:id: ${updateResult.success ? 'āœ…' : 'āŒ'} ${updateResult.status}`, + ); + + // Delete client + const deleteResult = await makeRequest( + 'DELETE', + `/manage/clients/${clientId}`, + ); + results.push(deleteResult); + console.log( + `āœ“ DELETE /manage/clients/:id: ${deleteResult.success ? 'āœ…' : 'āŒ'} ${deleteResult.status}`, + ); + } +} + +async function testReferences(projectId?: string) { + console.log('\nšŸ“š Testing References endpoints...\n'); + + if (!projectId) { + console.log(' āš ļø Skipping references tests - no project ID available'); + return; + } + + // Create reference + const createResult = await makeRequest('POST', '/manage/references', { + projectId, + title: `Test Reference ${Date.now()}`, + description: 'This is a test reference', + datetime: new Date().toISOString(), + }); + results.push(createResult); + console.log( + `āœ“ POST /manage/references: ${createResult.success ? 'āœ…' : 'āŒ'} ${createResult.status}`, + ); + if (createResult.error) console.log(` Error: ${createResult.error}`); + + const referenceId = createResult.data?.data?.id; + + if (referenceId) { + console.log(` Created reference: ${referenceId}`); + } + + // List references + const listResult = await makeRequest( + 'GET', + `/manage/references?projectId=${projectId}`, + ); + results.push(listResult); + console.log( + `āœ“ GET /manage/references: ${listResult.success ? 'āœ…' : 'āŒ'} ${listResult.status}`, + ); + if (listResult.data?.data?.length) { + console.log(` Found ${listResult.data.data.length} references`); + } + + if (referenceId) { + // Get reference + const getResult = await makeRequest( + 'GET', + `/manage/references/${referenceId}`, + ); + results.push(getResult); + console.log( + `āœ“ GET /manage/references/:id: ${getResult.success ? 'āœ…' : 'āŒ'} ${getResult.status}`, + ); + + // Update reference + const updateResult = await makeRequest( + 'PATCH', + `/manage/references/${referenceId}`, + { + title: 'Updated Test Reference', + description: 'Updated description', + datetime: new Date().toISOString(), + }, + ); + results.push(updateResult); + console.log( + `āœ“ PATCH /manage/references/:id: ${updateResult.success ? 'āœ…' : 'āŒ'} ${updateResult.status}`, + ); + + // Delete reference + const deleteResult = await makeRequest( + 'DELETE', + `/manage/references/${referenceId}`, + ); + results.push(deleteResult); + console.log( + `āœ“ DELETE /manage/references/:id: ${deleteResult.success ? 'āœ…' : 'āŒ'} ${deleteResult.status}`, + ); + } +} + +async function main() { + console.log('šŸš€ Testing Manage API Endpoints\n'); + console.log(`API Base URL: ${API_BASE_URL}`); + console.log(`Client ID: ${CLIENT_ID}\n`); + + try { + // Test projects first (creates a project we can use for other tests) + const { projectId } = await testProjects(); + + // Test clients + await testClients(projectId); + + // Test references (requires a project) + await testReferences(projectId); + + // Summary + console.log(`\n${'='.repeat(60)}`); + console.log('šŸ“Š Test Summary\n'); + const successful = results.filter((r) => r.success).length; + const failed = results.filter((r) => !r.success).length; + + console.log(`Total tests: ${results.length}`); + console.log(`āœ… Successful: ${successful}`); + console.log(`āŒ Failed: ${failed}\n`); + + if (failed > 0) { + console.log('Failed tests:'); + results + .filter((r) => !r.success) + .forEach((r) => { + console.log(` āŒ ${r.name} (${r.status})`); + if (r.error) console.log(` Error: ${r.error}`); + }); + } + } catch (error) { + console.error('Fatal error:', error); + process.exit(1); + } +} + +main().catch(console.error); diff --git a/apps/api/src/controllers/manage.controller.ts b/apps/api/src/controllers/manage.controller.ts new file mode 100644 index 00000000..e1d3bf67 --- /dev/null +++ b/apps/api/src/controllers/manage.controller.ts @@ -0,0 +1,649 @@ +import crypto from 'node:crypto'; +import { HttpError } from '@/utils/errors'; +import { stripTrailingSlash } from '@openpanel/common'; +import { hashPassword } from '@openpanel/common/server'; +import { + db, + getClientByIdCached, + getId, + getProjectByIdCached, +} from '@openpanel/db'; +import type { FastifyReply, FastifyRequest } from 'fastify'; +import { z } from 'zod'; + +// Validation schemas +const zCreateProject = z.object({ + name: z.string().min(1), + domain: z.string().url().or(z.literal('')).or(z.null()).optional(), + cors: z.array(z.string()).default([]), + crossDomain: z.boolean().optional().default(false), + types: z + .array(z.enum(['website', 'app', 'backend'])) + .optional() + .default([]), +}); + +const zUpdateProject = z.object({ + name: z.string().min(1).optional(), + domain: z.string().url().or(z.literal('')).or(z.null()).optional(), + cors: z.array(z.string()).optional(), + crossDomain: z.boolean().optional(), + allowUnsafeRevenueTracking: z.boolean().optional(), +}); + +const zCreateClient = z.object({ + name: z.string().min(1), + projectId: z.string().optional(), + type: z.enum(['read', 'write', 'root']).optional().default('write'), +}); + +const zUpdateClient = z.object({ + name: z.string().min(1).optional(), +}); + +const zCreateReference = z.object({ + projectId: z.string(), + title: z.string().min(1), + description: z.string().optional(), + datetime: z.string(), +}); + +const zUpdateReference = z.object({ + title: z.string().min(1).optional(), + description: z.string().optional(), + datetime: z.string().optional(), +}); + +// Projects CRUD +export async function listProjects( + request: FastifyRequest, + reply: FastifyReply, +) { + const projects = await db.project.findMany({ + where: { + organizationId: request.client!.organizationId, + deleteAt: null, + }, + orderBy: { + createdAt: 'desc', + }, + }); + + reply.send({ data: projects }); +} + +export async function getProject( + request: FastifyRequest<{ Params: { id: string } }>, + reply: FastifyReply, +) { + const project = await db.project.findFirst({ + where: { + id: request.params.id, + organizationId: request.client!.organizationId, + }, + }); + + if (!project) { + throw new HttpError('Project not found', { status: 404 }); + } + + reply.send({ data: project }); +} + +export async function createProject( + request: FastifyRequest<{ Body: z.infer }>, + reply: FastifyReply, +) { + const parsed = zCreateProject.safeParse(request.body); + + if (parsed.success === false) { + return reply.status(400).send({ + error: 'Bad Request', + message: 'Invalid request body', + details: parsed.error.errors, + }); + } + + const { name, domain, cors, crossDomain, types } = parsed.data; + + // Generate a default client secret + const secret = `sec_${crypto.randomBytes(10).toString('hex')}`; + const clientData = { + organizationId: request.client!.organizationId, + name: 'First client', + type: 'write' as const, + secret: await hashPassword(secret), + }; + + const project = await db.project.create({ + data: { + id: await getId('project', name), + organizationId: request.client!.organizationId, + name, + domain: domain ? stripTrailingSlash(domain) : null, + cors: cors.map((c) => stripTrailingSlash(c)), + crossDomain: crossDomain ?? false, + allowUnsafeRevenueTracking: false, + filters: [], + types, + clients: { + create: clientData, + }, + }, + include: { + clients: { + select: { + id: true, + }, + }, + }, + }); + + // Clear cache + await Promise.all([ + getProjectByIdCached.clear(project.id), + project.clients.map((client) => { + getClientByIdCached.clear(client.id); + }), + ]); + + reply.send({ + data: { + ...project, + client: project.clients[0] + ? { + id: project.clients[0].id, + secret, + } + : null, + }, + }); +} + +export async function updateProject( + request: FastifyRequest<{ + Params: { id: string }; + Body: z.infer; + }>, + reply: FastifyReply, +) { + const parsed = zUpdateProject.safeParse(request.body); + + if (parsed.success === false) { + return reply.status(400).send({ + error: 'Bad Request', + message: 'Invalid request body', + details: parsed.error.errors, + }); + } + + // Verify project exists and belongs to organization + const existing = await db.project.findFirst({ + where: { + id: request.params.id, + organizationId: request.client!.organizationId, + }, + include: { + clients: { + select: { + id: true, + }, + }, + }, + }); + + if (!existing) { + throw new HttpError('Project not found', { status: 404 }); + } + + const updateData: any = {}; + if (parsed.data.name !== undefined) { + updateData.name = parsed.data.name; + } + if (parsed.data.domain !== undefined) { + updateData.domain = parsed.data.domain + ? stripTrailingSlash(parsed.data.domain) + : null; + } + if (parsed.data.cors !== undefined) { + updateData.cors = parsed.data.cors.map((c) => stripTrailingSlash(c)); + } + if (parsed.data.crossDomain !== undefined) { + updateData.crossDomain = parsed.data.crossDomain; + } + if (parsed.data.allowUnsafeRevenueTracking !== undefined) { + updateData.allowUnsafeRevenueTracking = + parsed.data.allowUnsafeRevenueTracking; + } + + const project = await db.project.update({ + where: { + id: request.params.id, + }, + data: updateData, + }); + + // Clear cache + await Promise.all([ + getProjectByIdCached.clear(project.id), + existing.clients.map((client) => { + getClientByIdCached.clear(client.id); + }), + ]); + + reply.send({ data: project }); +} + +export async function deleteProject( + request: FastifyRequest<{ Params: { id: string } }>, + reply: FastifyReply, +) { + const project = await db.project.findFirst({ + where: { + id: request.params.id, + organizationId: request.client!.organizationId, + }, + }); + + if (!project) { + throw new HttpError('Project not found', { status: 404 }); + } + + await db.project.update({ + where: { + id: request.params.id, + }, + data: { + deleteAt: new Date(Date.now() + 24 * 60 * 60 * 1000), + }, + }); + + await getProjectByIdCached.clear(request.params.id); + + reply.send({ success: true }); +} + +// Clients CRUD +export async function listClients( + request: FastifyRequest<{ Querystring: { projectId?: string } }>, + reply: FastifyReply, +) { + const where: any = { + organizationId: request.client!.organizationId, + }; + + if (request.query.projectId) { + // Verify project belongs to organization + const project = await db.project.findFirst({ + where: { + id: request.query.projectId, + organizationId: request.client!.organizationId, + }, + }); + + if (!project) { + throw new HttpError('Project not found', { status: 404 }); + } + + where.projectId = request.query.projectId; + } + + const clients = await db.client.findMany({ + where, + orderBy: { + createdAt: 'desc', + }, + }); + + reply.send({ data: clients }); +} + +export async function getClient( + request: FastifyRequest<{ Params: { id: string } }>, + reply: FastifyReply, +) { + const client = await db.client.findFirst({ + where: { + id: request.params.id, + organizationId: request.client!.organizationId, + }, + }); + + if (!client) { + throw new HttpError('Client not found', { status: 404 }); + } + + reply.send({ data: client }); +} + +export async function createClient( + request: FastifyRequest<{ Body: z.infer }>, + reply: FastifyReply, +) { + const parsed = zCreateClient.safeParse(request.body); + + if (parsed.success === false) { + return reply.status(400).send({ + error: 'Bad Request', + message: 'Invalid request body', + details: parsed.error.errors, + }); + } + + const { name, projectId, type } = parsed.data; + + // If projectId is provided, verify it belongs to organization + if (projectId) { + const project = await db.project.findFirst({ + where: { + id: projectId, + organizationId: request.client!.organizationId, + }, + }); + + if (!project) { + throw new HttpError('Project not found', { status: 404 }); + } + } + + // Generate secret + const secret = `sec_${crypto.randomBytes(10).toString('hex')}`; + + const client = await db.client.create({ + data: { + organizationId: request.client!.organizationId, + projectId: projectId || null, + name, + type: type || 'write', + secret: await hashPassword(secret), + }, + }); + + await getClientByIdCached.clear(client.id); + + reply.send({ + data: { + ...client, + secret, // Return plain secret only once + }, + }); +} + +export async function updateClient( + request: FastifyRequest<{ + Params: { id: string }; + Body: z.infer; + }>, + reply: FastifyReply, +) { + const parsed = zUpdateClient.safeParse(request.body); + + if (parsed.success === false) { + return reply.status(400).send({ + error: 'Bad Request', + message: 'Invalid request body', + details: parsed.error.errors, + }); + } + + // Verify client exists and belongs to organization + const existing = await db.client.findFirst({ + where: { + id: request.params.id, + organizationId: request.client!.organizationId, + }, + }); + + if (!existing) { + throw new HttpError('Client not found', { status: 404 }); + } + + const updateData: any = {}; + if (parsed.data.name !== undefined) { + updateData.name = parsed.data.name; + } + + const client = await db.client.update({ + where: { + id: request.params.id, + }, + data: updateData, + }); + + await getClientByIdCached.clear(client.id); + + reply.send({ data: client }); +} + +export async function deleteClient( + request: FastifyRequest<{ Params: { id: string } }>, + reply: FastifyReply, +) { + const client = await db.client.findFirst({ + where: { + id: request.params.id, + organizationId: request.client!.organizationId, + }, + }); + + if (!client) { + throw new HttpError('Client not found', { status: 404 }); + } + + await db.client.delete({ + where: { + id: request.params.id, + }, + }); + + await getClientByIdCached.clear(request.params.id); + + reply.send({ success: true }); +} + +// References CRUD +export async function listReferences( + request: FastifyRequest<{ Querystring: { projectId?: string } }>, + reply: FastifyReply, +) { + const where: any = {}; + + if (request.query.projectId) { + // Verify project belongs to organization + const project = await db.project.findFirst({ + where: { + id: request.query.projectId, + organizationId: request.client!.organizationId, + }, + }); + + if (!project) { + throw new HttpError('Project not found', { status: 404 }); + } + + where.projectId = request.query.projectId; + } else { + // If no projectId, get all projects in org and filter references + const projects = await db.project.findMany({ + where: { + organizationId: request.client!.organizationId, + }, + select: { id: true }, + }); + + where.projectId = { + in: projects.map((p) => p.id), + }; + } + + const references = await db.reference.findMany({ + where, + orderBy: { + createdAt: 'desc', + }, + }); + + reply.send({ data: references }); +} + +export async function getReference( + request: FastifyRequest<{ Params: { id: string } }>, + reply: FastifyReply, +) { + const reference = await db.reference.findUnique({ + where: { + id: request.params.id, + }, + include: { + project: { + select: { + organizationId: true, + }, + }, + }, + }); + + if (!reference) { + throw new HttpError('Reference not found', { status: 404 }); + } + + if (reference.project.organizationId !== request.client!.organizationId) { + throw new HttpError('Reference not found', { status: 404 }); + } + + reply.send({ data: reference }); +} + +export async function createReference( + request: FastifyRequest<{ Body: z.infer }>, + reply: FastifyReply, +) { + const parsed = zCreateReference.safeParse(request.body); + + if (parsed.success === false) { + return reply.status(400).send({ + error: 'Bad Request', + message: 'Invalid request body', + details: parsed.error.errors, + }); + } + + const { projectId, title, description, datetime } = parsed.data; + + // Verify project belongs to organization + const project = await db.project.findFirst({ + where: { + id: projectId, + organizationId: request.client!.organizationId, + }, + }); + + if (!project) { + throw new HttpError('Project not found', { status: 404 }); + } + + const reference = await db.reference.create({ + data: { + projectId, + title, + description: description || null, + date: new Date(datetime), + }, + }); + + reply.send({ data: reference }); +} + +export async function updateReference( + request: FastifyRequest<{ + Params: { id: string }; + Body: z.infer; + }>, + reply: FastifyReply, +) { + const parsed = zUpdateReference.safeParse(request.body); + + if (parsed.success === false) { + return reply.status(400).send({ + error: 'Bad Request', + message: 'Invalid request body', + details: parsed.error.errors, + }); + } + + // Verify reference exists and belongs to organization + const existing = await db.reference.findUnique({ + where: { + id: request.params.id, + }, + include: { + project: { + select: { + organizationId: true, + }, + }, + }, + }); + + if (!existing) { + throw new HttpError('Reference not found', { status: 404 }); + } + + if (existing.project.organizationId !== request.client!.organizationId) { + throw new HttpError('Reference not found', { status: 404 }); + } + + const updateData: any = {}; + if (parsed.data.title !== undefined) { + updateData.title = parsed.data.title; + } + if (parsed.data.description !== undefined) { + updateData.description = parsed.data.description ?? null; + } + if (parsed.data.datetime !== undefined) { + updateData.date = new Date(parsed.data.datetime); + } + + const reference = await db.reference.update({ + where: { + id: request.params.id, + }, + data: updateData, + }); + + reply.send({ data: reference }); +} + +export async function deleteReference( + request: FastifyRequest<{ Params: { id: string } }>, + reply: FastifyReply, +) { + const reference = await db.reference.findUnique({ + where: { + id: request.params.id, + }, + include: { + project: { + select: { + organizationId: true, + }, + }, + }, + }); + + if (!reference) { + throw new HttpError('Reference not found', { status: 404 }); + } + + if (reference.project.organizationId !== request.client!.organizationId) { + throw new HttpError('Reference not found', { status: 404 }); + } + + await db.reference.delete({ + where: { + id: request.params.id, + }, + }); + + reply.send({ success: true }); +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 7d3632ca..28a07ced 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -38,6 +38,7 @@ import exportRouter from './routes/export.router'; import importRouter from './routes/import.router'; import insightsRouter from './routes/insights.router'; import liveRouter from './routes/live.router'; +import manageRouter from './routes/manage.router'; import miscRouter from './routes/misc.router'; import oauthRouter from './routes/oauth-callback.router'; import profileRouter from './routes/profile.router'; @@ -194,6 +195,7 @@ const startServer = async () => { instance.register(importRouter, { prefix: '/import' }); instance.register(insightsRouter, { prefix: '/insights' }); instance.register(trackRouter, { prefix: '/track' }); + instance.register(manageRouter, { prefix: '/manage' }); // Keep existing endpoints for backward compatibility instance.get('/healthcheck', healthcheck); // New Kubernetes-style health endpoints diff --git a/apps/api/src/routes/manage.router.ts b/apps/api/src/routes/manage.router.ts new file mode 100644 index 00000000..70ecfd47 --- /dev/null +++ b/apps/api/src/routes/manage.router.ts @@ -0,0 +1,132 @@ +import * as controller from '@/controllers/manage.controller'; +import { validateManageRequest } from '@/utils/auth'; +import { activateRateLimiter } from '@/utils/rate-limiter'; +import { Prisma } from '@openpanel/db'; +import type { FastifyPluginCallback, FastifyRequest } from 'fastify'; + +const manageRouter: FastifyPluginCallback = async (fastify) => { + await activateRateLimiter({ + fastify, + max: 20, + timeWindow: '10 seconds', + }); + + fastify.addHook('preHandler', async (req: FastifyRequest, reply) => { + try { + const client = await validateManageRequest(req.headers); + req.client = client; + } catch (e) { + if (e instanceof Prisma.PrismaClientKnownRequestError) { + return reply.status(401).send({ + error: 'Unauthorized', + message: 'Client ID seems to be malformed', + }); + } + + if (e instanceof Error) { + return reply + .status(401) + .send({ error: 'Unauthorized', message: e.message }); + } + + return reply + .status(401) + .send({ error: 'Unauthorized', message: 'Unexpected error' }); + } + }); + + // Projects routes + fastify.route({ + method: 'GET', + url: '/projects', + handler: controller.listProjects, + }); + + fastify.route({ + method: 'GET', + url: '/projects/:id', + handler: controller.getProject, + }); + + fastify.route({ + method: 'POST', + url: '/projects', + handler: controller.createProject, + }); + + fastify.route({ + method: 'PATCH', + url: '/projects/:id', + handler: controller.updateProject, + }); + + fastify.route({ + method: 'DELETE', + url: '/projects/:id', + handler: controller.deleteProject, + }); + + // Clients routes + fastify.route({ + method: 'GET', + url: '/clients', + handler: controller.listClients, + }); + + fastify.route({ + method: 'GET', + url: '/clients/:id', + handler: controller.getClient, + }); + + fastify.route({ + method: 'POST', + url: '/clients', + handler: controller.createClient, + }); + + fastify.route({ + method: 'PATCH', + url: '/clients/:id', + handler: controller.updateClient, + }); + + fastify.route({ + method: 'DELETE', + url: '/clients/:id', + handler: controller.deleteClient, + }); + + // References routes + fastify.route({ + method: 'GET', + url: '/references', + handler: controller.listReferences, + }); + + fastify.route({ + method: 'GET', + url: '/references/:id', + handler: controller.getReference, + }); + + fastify.route({ + method: 'POST', + url: '/references', + handler: controller.createReference, + }); + + fastify.route({ + method: 'PATCH', + url: '/references/:id', + handler: controller.updateReference, + }); + + fastify.route({ + method: 'DELETE', + url: '/references/:id', + handler: controller.deleteReference, + }); +}; + +export default manageRouter; diff --git a/apps/api/src/utils/auth.ts b/apps/api/src/utils/auth.ts index ef5a8952..23e8758f 100644 --- a/apps/api/src/utils/auth.ts +++ b/apps/api/src/utils/auth.ts @@ -236,3 +236,40 @@ export async function validateImportRequest( return client; } + +export async function validateManageRequest( + headers: RawRequestDefaultExpression['headers'], +): Promise { + const clientId = headers['openpanel-client-id'] as string; + const clientSecret = (headers['openpanel-client-secret'] as string) || ''; + + if ( + !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test( + clientId, + ) + ) { + throw new Error('Manage: Client ID must be a valid UUIDv4'); + } + + const client = await getClientByIdCached(clientId); + + if (!client) { + throw new Error('Manage: Invalid client id'); + } + + if (!client.secret) { + throw new Error('Manage: Client has no secret'); + } + + if (client.type !== ClientType.root) { + throw new Error( + 'Manage: Only root clients are allowed to manage resources', + ); + } + + if (!(await verifyPassword(clientSecret, client.secret))) { + throw new Error('Manage: Invalid client secret'); + } + + return client; +} diff --git a/apps/public/content/docs/api/authentication.mdx b/apps/public/content/docs/api/authentication.mdx index f2d6a054..8af90cda 100644 --- a/apps/public/content/docs/api/authentication.mdx +++ b/apps/public/content/docs/api/authentication.mdx @@ -7,11 +7,12 @@ description: Learn how to authenticate with the OpenPanel API using client crede To authenticate with the OpenPanel API, you need to use your `clientId` and `clientSecret`. Different API endpoints may require different access levels: -- **Track API**: Default client works with `track` mode +- **Track API**: Default client works with `write` mode - **Export API**: Requires `read` or `root` mode - **Insights API**: Requires `read` or `root` mode +- **Manage API**: Requires `root` mode only -The default client does not have access to the Export or Insights APIs. +The default client (created with a project) has `write` mode and does not have access to the Export, Insights, or Manage APIs. You'll need to create additional clients with appropriate access levels. ## Headers @@ -48,15 +49,29 @@ If authentication fails, you'll receive a `401 Unauthorized` response: Common authentication errors: - Invalid client ID or secret -- Client doesn't have required permissions -- Malformed client ID +- Client doesn't have required permissions (e.g., trying to access Manage API with a non-root client) +- Malformed client ID (must be a valid UUIDv4) +- Client type mismatch (e.g., `write` client trying to access Export API) + +## Client Types + +OpenPanel supports three client types with different access levels: + +| Type | Description | Access | +|------|-------------|--------| +| `write` | Write access | Track API only | +| `read` | Read-only access | Export API, Insights API | +| `root` | Full access | All APIs including Manage API | + +**Note**: Root clients have organization-wide access and can manage all resources. Use root clients carefully and store their credentials securely. ## Rate Limiting The API implements rate limiting to prevent abuse. Rate limits vary by endpoint: - **Track API**: Higher limits for event tracking -- **Export/Insights APIs**: Lower limits for data retrieval +- **Export/Insights APIs**: 100 requests per 10 seconds +- **Manage API**: 20 requests per 10 seconds If you exceed the rate limit, you'll receive a `429 Too Many Requests` response. Implement exponential backoff for retries. diff --git a/apps/public/content/docs/api/manage/clients.mdx b/apps/public/content/docs/api/manage/clients.mdx new file mode 100644 index 00000000..91c9fd2e --- /dev/null +++ b/apps/public/content/docs/api/manage/clients.mdx @@ -0,0 +1,332 @@ +--- +title: Clients +description: Manage API clients for your OpenPanel projects. Create, read, update, and delete clients with different access levels. +--- + +## Authentication + +To authenticate with the Clients API, you need to use your `clientId` and `clientSecret` from a root client. Root clients have organization-wide access. + +For detailed authentication information, see the [Authentication](/docs/api/authentication) guide. + +Include the following headers with your requests: +- `openpanel-client-id`: Your OpenPanel root client ID +- `openpanel-client-secret`: Your OpenPanel root client secret + +## Base URL + +All Clients API requests should be made to: + +``` +https://api.openpanel.dev/manage/clients +``` + +## Client Types + +OpenPanel supports three client types with different access levels: + +| Type | Description | Use Case | +|------|-------------|----------| +| `read` | Read-only access | Export data, view insights, read-only operations | +| `write` | Write access | Track events, send data to OpenPanel | +| `root` | Full access | Manage resources, access Manage API | + +**Note**: Only `root` clients can access the Manage API. + +## Endpoints + +### List Clients + +Retrieve all clients in your organization, optionally filtered by project. + +``` +GET /manage/clients +``` + +#### Query Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| `projectId` | string | Optional. Filter clients by project ID | + +#### Example Request + +```bash +# List all clients +curl 'https://api.openpanel.dev/manage/clients' \ + -H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \ + -H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' + +# List clients for a specific project +curl 'https://api.openpanel.dev/manage/clients?projectId=my-project' \ + -H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \ + -H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' +``` + +#### Response + +```json +{ + "data": [ + { + "id": "fa0c2780-55f2-4d9e-bea0-da2e02c7b1a9", + "name": "First client", + "type": "write", + "projectId": "my-project", + "organizationId": "org_123", + "ignoreCorsAndSecret": false, + "createdAt": "2024-01-15T10:30:00.000Z", + "updatedAt": "2024-01-15T10:30:00.000Z" + }, + { + "id": "b8904453-863d-4e04-8ebc-8abae30ffb1a", + "name": "Read-only Client", + "type": "read", + "projectId": "my-project", + "organizationId": "org_123", + "ignoreCorsAndSecret": false, + "createdAt": "2024-01-15T11:00:00.000Z", + "updatedAt": "2024-01-15T11:00:00.000Z" + } + ] +} +``` + +**Note**: Client secrets are never returned in list or get responses for security reasons. + +### Get Client + +Retrieve a specific client by ID. + +``` +GET /manage/clients/{id} +``` + +#### Path Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| `id` | string | The ID of the client (UUID) | + +#### Example Request + +```bash +curl 'https://api.openpanel.dev/manage/clients/fa0c2780-55f2-4d9e-bea0-da2e02c7b1a9' \ + -H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \ + -H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' +``` + +#### Response + +```json +{ + "data": { + "id": "fa0c2780-55f2-4d9e-bea0-da2e02c7b1a9", + "name": "First client", + "type": "write", + "projectId": "my-project", + "organizationId": "org_123", + "ignoreCorsAndSecret": false, + "createdAt": "2024-01-15T10:30:00.000Z", + "updatedAt": "2024-01-15T10:30:00.000Z" + } +} +``` + +### Create Client + +Create a new API client. A secure secret is automatically generated and returned once. + +``` +POST /manage/clients +``` + +#### Request Body + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `name` | string | Yes | Client name (minimum 1 character) | +| `projectId` | string | No | Associate client with a specific project | +| `type` | string | No | Client type: `read`, `write`, or `root` (default: `write`) | + +#### Example Request + +```bash +curl -X POST 'https://api.openpanel.dev/manage/clients' \ + -H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \ + -H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' \ + -H 'Content-Type: application/json' \ + -d '{ + "name": "My API Client", + "projectId": "my-project", + "type": "read" + }' +``` + +#### Response + +```json +{ + "data": { + "id": "b8904453-863d-4e04-8ebc-8abae30ffb1a", + "name": "My API Client", + "type": "read", + "projectId": "my-project", + "organizationId": "org_123", + "ignoreCorsAndSecret": false, + "createdAt": "2024-01-15T11:00:00.000Z", + "updatedAt": "2024-01-15T11:00:00.000Z", + "secret": "sec_b2521ca283bf903b46b3" + } +} +``` + +**Important**: The `secret` field is only returned once when the client is created. Store it securely immediately. You cannot retrieve the secret later - if lost, you'll need to delete and recreate the client. + +### Update Client + +Update an existing client's name. + +``` +PATCH /manage/clients/{id} +``` + +#### Path Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| `id` | string | The ID of the client (UUID) | + +#### Request Body + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `name` | string | No | New client name (minimum 1 character) | + +**Note**: Currently, only the `name` field can be updated. To change the client type or project association, delete and recreate the client. + +#### Example Request + +```bash +curl -X PATCH 'https://api.openpanel.dev/manage/clients/b8904453-863d-4e04-8ebc-8abae30ffb1a' \ + -H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \ + -H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' \ + -H 'Content-Type: application/json' \ + -d '{ + "name": "Updated Client Name" + }' +``` + +#### Response + +```json +{ + "data": { + "id": "b8904453-863d-4e04-8ebc-8abae30ffb1a", + "name": "Updated Client Name", + "type": "read", + "projectId": "my-project", + "organizationId": "org_123", + "ignoreCorsAndSecret": false, + "createdAt": "2024-01-15T11:00:00.000Z", + "updatedAt": "2024-01-15T11:30:00.000Z" + } +} +``` + +### Delete Client + +Permanently delete a client. This action cannot be undone. + +``` +DELETE /manage/clients/{id} +``` + +#### Path Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| `id` | string | The ID of the client (UUID) | + +#### Example Request + +```bash +curl -X DELETE 'https://api.openpanel.dev/manage/clients/b8904453-863d-4e04-8ebc-8abae30ffb1a' \ + -H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \ + -H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' +``` + +#### Response + +```json +{ + "success": true +} +``` + +**Warning**: Deleting a client is permanent. Any applications using this client will immediately lose access. Make sure to update your applications before deleting a client. + +## Error Handling + +The API uses standard HTTP response codes. Common error responses: + +### 400 Bad Request + +```json +{ + "error": "Bad Request", + "message": "Invalid request body", + "details": [ + { + "path": ["name"], + "message": "String must contain at least 1 character(s)" + } + ] +} +``` + +### 401 Unauthorized + +```json +{ + "error": "Unauthorized", + "message": "Manage: Only root clients are allowed to manage resources" +} +``` + +### 404 Not Found + +```json +{ + "error": "Not Found", + "message": "Client not found" +} +``` + +### 429 Too Many Requests + +Rate limiting response includes headers indicating your rate limit status. + +## Rate Limiting + +The Clients API implements rate limiting: +- **20 requests per 10 seconds** per client +- Rate limit headers included in responses +- Implement exponential backoff for retries + +## Security Best Practices + +1. **Store Secrets Securely**: Client secrets are only shown once on creation. Store them in secure credential management systems +2. **Use Appropriate Client Types**: Use the minimum required access level for each use case +3. **Rotate Secrets Regularly**: Delete old clients and create new ones to rotate secrets +4. **Never Expose Secrets**: Never commit client secrets to version control or expose them in client-side code +5. **Monitor Client Usage**: Regularly review and remove unused clients + +## Notes + +- Client IDs are UUIDs (Universally Unique Identifiers) +- Client secrets are automatically generated with the format `sec_` followed by random hex characters +- Secrets are hashed using argon2 before storage +- Clients can be associated with a project or exist at the organization level +- Clients are scoped to your organization - you can only manage clients in your organization +- The `ignoreCorsAndSecret` field is an advanced setting that bypasses CORS and secret validation (use with caution) diff --git a/apps/public/content/docs/api/manage/index.mdx b/apps/public/content/docs/api/manage/index.mdx new file mode 100644 index 00000000..64b7ed89 --- /dev/null +++ b/apps/public/content/docs/api/manage/index.mdx @@ -0,0 +1,140 @@ +--- +title: Manage API Overview +description: Programmatically manage projects, clients, and references in your OpenPanel organization using the Manage API. +--- + +## Overview + +The Manage API provides programmatic access to manage your OpenPanel resources including projects, clients, and references. This API is designed for automation, infrastructure-as-code, and administrative tasks. + +## Authentication + +The Manage API requires a **root client** for authentication. Root clients have organization-wide access and can manage all resources within their organization. + +To authenticate with the Manage API, you need: +- A client with `type: 'root'` +- Your `clientId` and `clientSecret` + +For detailed authentication information, see the [Authentication](/docs/api/authentication) guide. + +Include the following headers with your requests: +- `openpanel-client-id`: Your OpenPanel root client ID +- `openpanel-client-secret`: Your OpenPanel root client secret + +## Base URL + +All Manage API requests should be made to: + +``` +https://api.openpanel.dev/manage +``` + +## Available Resources + +The Manage API provides CRUD operations for three resource types: + +### Projects + +Manage your analytics projects programmatically: +- **[Projects Documentation](/docs/api/manage/projects)** - Create, read, update, and delete projects +- Automatically creates a default write client when creating a project +- Supports project configuration including domains, CORS settings, and project types + +### Clients + +Manage API clients for your projects: +- **[Clients Documentation](/docs/api/manage/clients)** - Create, read, update, and delete clients +- Supports different client types: `read`, `write`, and `root` +- Auto-generates secure secrets on creation (returned once) + +### References + +Manage reference points for your analytics: +- **[References Documentation](/docs/api/manage/references)** - Create, read, update, and delete references +- Useful for marking important dates or events in your analytics timeline +- Can be filtered by project + +## Common Features + +All endpoints share these common characteristics: + +### Organization Scope + +All operations are scoped to your organization. You can only manage resources that belong to your organization. + +### Response Format + +Successful responses follow this structure: + +```json +{ + "data": { + // Resource data + } +} +``` + +For list endpoints: + +```json +{ + "data": [ + // Array of resources + ] +} +``` + +### Error Handling + +The API uses standard HTTP response codes: + +- `200 OK` - Request successful +- `400 Bad Request` - Invalid request parameters +- `401 Unauthorized` - Authentication failed +- `404 Not Found` - Resource not found +- `429 Too Many Requests` - Rate limit exceeded + +## Rate Limiting + +The Manage API implements rate limiting: +- **20 requests per 10 seconds** per client +- Rate limit headers included in responses +- Implement exponential backoff for retries + +## Use Cases + +The Manage API is ideal for: + +- **Infrastructure as Code**: Manage OpenPanel resources alongside your application infrastructure +- **Automation**: Automatically create projects and clients for new deployments +- **Bulk Operations**: Programmatically manage multiple resources +- **CI/CD Integration**: Set up projects and clients as part of your deployment pipeline +- **Administrative Tools**: Build custom admin interfaces + +## Security Best Practices + +1. **Root Clients Only**: Only root clients can access the Manage API +2. **Store Credentials Securely**: Never expose root client credentials in client-side code +3. **Use HTTPS**: Always use HTTPS for API requests +4. **Rotate Credentials**: Regularly rotate your root client credentials +5. **Limit Access**: Restrict root client creation to trusted administrators + +## Getting Started + +1. **Create a Root Client**: Use the dashboard to create a root client in your organization +2. **Store Credentials**: Securely store your root client ID and secret +3. **Make Your First Request**: Start with listing projects to verify authentication + +Example: + +```bash +curl 'https://api.openpanel.dev/manage/projects' \ + -H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \ + -H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' +``` + +## Next Steps + +- Read the [Projects documentation](/docs/api/manage/projects) to manage projects +- Read the [Clients documentation](/docs/api/manage/clients) to manage API clients +- Read the [References documentation](/docs/api/manage/references) to manage reference points diff --git a/apps/public/content/docs/api/manage/meta.json b/apps/public/content/docs/api/manage/meta.json new file mode 100644 index 00000000..f26e1e9b --- /dev/null +++ b/apps/public/content/docs/api/manage/meta.json @@ -0,0 +1,5 @@ +{ + "title": "Manage", + "pages": ["projects", "clients", "references"], + "defaultOpen": false +} diff --git a/apps/public/content/docs/api/manage/projects.mdx b/apps/public/content/docs/api/manage/projects.mdx new file mode 100644 index 00000000..d12e2e29 --- /dev/null +++ b/apps/public/content/docs/api/manage/projects.mdx @@ -0,0 +1,327 @@ +--- +title: Projects +description: Manage your OpenPanel projects programmatically. Create, read, update, and delete projects using the Manage API. +--- + +## Authentication + +To authenticate with the Projects API, you need to use your `clientId` and `clientSecret` from a root client. Root clients have organization-wide access. + +For detailed authentication information, see the [Authentication](/docs/api/authentication) guide. + +Include the following headers with your requests: +- `openpanel-client-id`: Your OpenPanel root client ID +- `openpanel-client-secret`: Your OpenPanel root client secret + +## Base URL + +All Projects API requests should be made to: + +``` +https://api.openpanel.dev/manage/projects +``` + +## Endpoints + +### List Projects + +Retrieve all projects in your organization. + +``` +GET /manage/projects +``` + +#### Example Request + +```bash +curl 'https://api.openpanel.dev/manage/projects' \ + -H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \ + -H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' +``` + +#### Response + +```json +{ + "data": [ + { + "id": "my-project", + "name": "My Project", + "organizationId": "org_123", + "domain": "https://example.com", + "cors": ["https://example.com", "https://www.example.com"], + "crossDomain": false, + "allowUnsafeRevenueTracking": false, + "filters": [], + "types": ["website"], + "eventsCount": 0, + "createdAt": "2024-01-15T10:30:00.000Z", + "updatedAt": "2024-01-15T10:30:00.000Z", + "deleteAt": null + } + ] +} +``` + +### Get Project + +Retrieve a specific project by ID. + +``` +GET /manage/projects/{id} +``` + +#### Path Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| `id` | string | The ID of the project | + +#### Example Request + +```bash +curl 'https://api.openpanel.dev/manage/projects/my-project' \ + -H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \ + -H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' +``` + +#### Response + +```json +{ + "data": { + "id": "my-project", + "name": "My Project", + "organizationId": "org_123", + "domain": "https://example.com", + "cors": ["https://example.com"], + "crossDomain": false, + "allowUnsafeRevenueTracking": false, + "filters": [], + "types": ["website"], + "eventsCount": 0, + "createdAt": "2024-01-15T10:30:00.000Z", + "updatedAt": "2024-01-15T10:30:00.000Z", + "deleteAt": null + } +} +``` + +### Create Project + +Create a new project in your organization. A default write client is automatically created with the project. + +``` +POST /manage/projects +``` + +#### Request Body + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `name` | string | Yes | Project name (minimum 1 character) | +| `domain` | string \| null | No | Primary domain for the project (URL format or empty string) | +| `cors` | string[] | No | Array of allowed CORS origins (default: `[]`) | +| `crossDomain` | boolean | No | Enable cross-domain tracking (default: `false`) | +| `types` | string[] | No | Project types: `website`, `app`, `backend` (default: `[]`) | + +#### Project Types + +- `website`: Web-based project +- `app`: Mobile application +- `backend`: Backend/server-side project + +#### Example Request + +```bash +curl -X POST 'https://api.openpanel.dev/manage/projects' \ + -H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \ + -H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' \ + -H 'Content-Type: application/json' \ + -d '{ + "name": "My New Project", + "domain": "https://example.com", + "cors": ["https://example.com", "https://www.example.com"], + "crossDomain": false, + "types": ["website"] + }' +``` + +#### Response + +```json +{ + "data": { + "id": "my-new-project", + "name": "My New Project", + "organizationId": "org_123", + "domain": "https://example.com", + "cors": ["https://example.com", "https://www.example.com"], + "crossDomain": false, + "allowUnsafeRevenueTracking": false, + "filters": [], + "types": ["website"], + "eventsCount": 0, + "createdAt": "2024-01-15T10:30:00.000Z", + "updatedAt": "2024-01-15T10:30:00.000Z", + "deleteAt": null, + "client": { + "id": "fa0c2780-55f2-4d9e-bea0-da2e02c7b1a9", + "secret": "sec_6c8ae85a092d6c66b242" + } + } +} +``` + +**Important**: The `client.secret` is only returned once when the project is created. Store it securely immediately. + +### Update Project + +Update an existing project's configuration. + +``` +PATCH /manage/projects/{id} +``` + +#### Path Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| `id` | string | The ID of the project | + +#### Request Body + +All fields are optional. Only include fields you want to update. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `name` | string | Project name (minimum 1 character) | +| `domain` | string \| null | Primary domain (URL format, empty string, or null) | +| `cors` | string[] | Array of allowed CORS origins | +| `crossDomain` | boolean | Enable cross-domain tracking | +| `allowUnsafeRevenueTracking` | boolean | Allow revenue tracking without client secret | + +#### Example Request + +```bash +curl -X PATCH 'https://api.openpanel.dev/manage/projects/my-project' \ + -H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \ + -H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' \ + -H 'Content-Type: application/json' \ + -d '{ + "name": "Updated Project Name", + "crossDomain": true, + "allowUnsafeRevenueTracking": false + }' +``` + +#### Response + +```json +{ + "data": { + "id": "my-project", + "name": "Updated Project Name", + "organizationId": "org_123", + "domain": "https://example.com", + "cors": ["https://example.com"], + "crossDomain": true, + "allowUnsafeRevenueTracking": false, + "filters": [], + "types": ["website"], + "eventsCount": 0, + "createdAt": "2024-01-15T10:30:00.000Z", + "updatedAt": "2024-01-15T11:00:00.000Z", + "deleteAt": null + } +} +``` + +### Delete Project + +Soft delete a project. The project will be scheduled for deletion after 24 hours. + +``` +DELETE /manage/projects/{id} +``` + +#### Path Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| `id` | string | The ID of the project | + +#### Example Request + +```bash +curl -X DELETE 'https://api.openpanel.dev/manage/projects/my-project' \ + -H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \ + -H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' +``` + +#### Response + +```json +{ + "success": true +} +``` + +**Note**: Projects are soft-deleted. The `deleteAt` field is set to 24 hours in the future. You can cancel deletion by updating the project before the deletion time. + +## Error Handling + +The API uses standard HTTP response codes. Common error responses: + +### 400 Bad Request + +```json +{ + "error": "Bad Request", + "message": "Invalid request body", + "details": [ + { + "path": ["name"], + "message": "String must contain at least 1 character(s)" + } + ] +} +``` + +### 401 Unauthorized + +```json +{ + "error": "Unauthorized", + "message": "Manage: Only root clients are allowed to manage resources" +} +``` + +### 404 Not Found + +```json +{ + "error": "Not Found", + "message": "Project not found" +} +``` + +### 429 Too Many Requests + +Rate limiting response includes headers indicating your rate limit status. + +## Rate Limiting + +The Projects API implements rate limiting: +- **20 requests per 10 seconds** per client +- Rate limit headers included in responses +- Implement exponential backoff for retries + +## Notes + +- Project IDs are automatically generated from the project name using a slug format +- If a project ID already exists, a numeric suffix is added +- CORS domains are automatically normalized (trailing slashes removed) +- The default client created with a project has `type: 'write'` +- Projects are scoped to your organization - you can only manage projects in your organization +- Soft-deleted projects are excluded from list endpoints diff --git a/apps/public/content/docs/api/manage/references.mdx b/apps/public/content/docs/api/manage/references.mdx new file mode 100644 index 00000000..54b799a0 --- /dev/null +++ b/apps/public/content/docs/api/manage/references.mdx @@ -0,0 +1,344 @@ +--- +title: References +description: Manage reference points for your OpenPanel projects. References are useful for marking important dates or events in your analytics timeline. +--- + +## Authentication + +To authenticate with the References API, you need to use your `clientId` and `clientSecret` from a root client. Root clients have organization-wide access. + +For detailed authentication information, see the [Authentication](/docs/api/authentication) guide. + +Include the following headers with your requests: +- `openpanel-client-id`: Your OpenPanel root client ID +- `openpanel-client-secret`: Your OpenPanel root client secret + +## Base URL + +All References API requests should be made to: + +``` +https://api.openpanel.dev/manage/references +``` + +## What are References? + +References are markers you can add to your analytics timeline to track important events such as: +- Product launches +- Marketing campaign start dates +- Feature releases +- Website redesigns +- Major announcements + +References appear in your analytics charts and help you correlate changes in metrics with specific events. + +## Endpoints + +### List References + +Retrieve all references in your organization, optionally filtered by project. + +``` +GET /manage/references +``` + +#### Query Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| `projectId` | string | Optional. Filter references by project ID | + +#### Example Request + +```bash +# List all references +curl 'https://api.openpanel.dev/manage/references' \ + -H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \ + -H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' + +# List references for a specific project +curl 'https://api.openpanel.dev/manage/references?projectId=my-project' \ + -H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \ + -H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' +``` + +#### Response + +```json +{ + "data": [ + { + "id": "1af09627-2dd7-4b37-9e5c-6ffa7e450e85", + "title": "Product Launch", + "description": "Version 2.0 released", + "date": "2024-01-15T10:00:00.000Z", + "projectId": "my-project", + "createdAt": "2024-01-10T08:00:00.000Z", + "updatedAt": "2024-01-10T08:00:00.000Z" + }, + { + "id": "2bf19738-3ee8-4c48-af6d-7ggb8f561f96", + "title": "Marketing Campaign Start", + "description": "Q1 2024 campaign launched", + "date": "2024-01-20T09:00:00.000Z", + "projectId": "my-project", + "createdAt": "2024-01-18T10:00:00.000Z", + "updatedAt": "2024-01-18T10:00:00.000Z" + } + ] +} +``` + +### Get Reference + +Retrieve a specific reference by ID. + +``` +GET /manage/references/{id} +``` + +#### Path Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| `id` | string | The ID of the reference (UUID) | + +#### Example Request + +```bash +curl 'https://api.openpanel.dev/manage/references/1af09627-2dd7-4b37-9e5c-6ffa7e450e85' \ + -H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \ + -H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' +``` + +#### Response + +```json +{ + "data": { + "id": "1af09627-2dd7-4b37-9e5c-6ffa7e450e85", + "title": "Product Launch", + "description": "Version 2.0 released", + "date": "2024-01-15T10:00:00.000Z", + "projectId": "my-project", + "createdAt": "2024-01-10T08:00:00.000Z", + "updatedAt": "2024-01-10T08:00:00.000Z" + } +} +``` + +### Create Reference + +Create a new reference point for a project. + +``` +POST /manage/references +``` + +#### Request Body + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `projectId` | string | Yes | The ID of the project this reference belongs to | +| `title` | string | Yes | Reference title (minimum 1 character) | +| `description` | string | No | Optional description or notes | +| `datetime` | string | Yes | Date and time for the reference (ISO 8601 format) | + +#### Example Request + +```bash +curl -X POST 'https://api.openpanel.dev/manage/references' \ + -H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \ + -H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' \ + -H 'Content-Type: application/json' \ + -d '{ + "projectId": "my-project", + "title": "Product Launch", + "description": "Version 2.0 released with new features", + "datetime": "2024-01-15T10:00:00.000Z" + }' +``` + +#### Response + +```json +{ + "data": { + "id": "1af09627-2dd7-4b37-9e5c-6ffa7e450e85", + "title": "Product Launch", + "description": "Version 2.0 released with new features", + "date": "2024-01-15T10:00:00.000Z", + "projectId": "my-project", + "createdAt": "2024-01-10T08:00:00.000Z", + "updatedAt": "2024-01-10T08:00:00.000Z" + } +} +``` + +**Note**: The `date` field in the response is parsed from the `datetime` string you provided. + +### Update Reference + +Update an existing reference. + +``` +PATCH /manage/references/{id} +``` + +#### Path Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| `id` | string | The ID of the reference (UUID) | + +#### Request Body + +All fields are optional. Only include fields you want to update. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `title` | string | Reference title (minimum 1 character) | +| `description` | string \| null | Description or notes (set to `null` to clear) | +| `datetime` | string | Date and time for the reference (ISO 8601 format) | + +#### Example Request + +```bash +curl -X PATCH 'https://api.openpanel.dev/manage/references/1af09627-2dd7-4b37-9e5c-6ffa7e450e85' \ + -H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \ + -H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' \ + -H 'Content-Type: application/json' \ + -d '{ + "title": "Product Launch v2.1", + "description": "Updated: Version 2.1 released with bug fixes", + "datetime": "2024-01-15T10:00:00.000Z" + }' +``` + +#### Response + +```json +{ + "data": { + "id": "1af09627-2dd7-4b37-9e5c-6ffa7e450e85", + "title": "Product Launch v2.1", + "description": "Updated: Version 2.1 released with bug fixes", + "date": "2024-01-15T10:00:00.000Z", + "projectId": "my-project", + "createdAt": "2024-01-10T08:00:00.000Z", + "updatedAt": "2024-01-10T09:30:00.000Z" + } +} +``` + +### Delete Reference + +Permanently delete a reference. This action cannot be undone. + +``` +DELETE /manage/references/{id} +``` + +#### Path Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| `id` | string | The ID of the reference (UUID) | + +#### Example Request + +```bash +curl -X DELETE 'https://api.openpanel.dev/manage/references/1af09627-2dd7-4b37-9e5c-6ffa7e450e85' \ + -H 'openpanel-client-id: YOUR_ROOT_CLIENT_ID' \ + -H 'openpanel-client-secret: YOUR_ROOT_CLIENT_SECRET' +``` + +#### Response + +```json +{ + "success": true +} +``` + +## Error Handling + +The API uses standard HTTP response codes. Common error responses: + +### 400 Bad Request + +```json +{ + "error": "Bad Request", + "message": "Invalid request body", + "details": [ + { + "path": ["title"], + "message": "String must contain at least 1 character(s)" + } + ] +} +``` + +### 401 Unauthorized + +```json +{ + "error": "Unauthorized", + "message": "Manage: Only root clients are allowed to manage resources" +} +``` + +### 404 Not Found + +```json +{ + "error": "Not Found", + "message": "Reference not found" +} +``` + +This error can occur if: +- The reference ID doesn't exist +- The reference belongs to a different organization + +### 429 Too Many Requests + +Rate limiting response includes headers indicating your rate limit status. + +## Rate Limiting + +The References API implements rate limiting: +- **20 requests per 10 seconds** per client +- Rate limit headers included in responses +- Implement exponential backoff for retries + +## Date Format + +References use ISO 8601 date format. Examples: + +- `2024-01-15T10:00:00.000Z` - UTC timezone +- `2024-01-15T10:00:00-05:00` - Eastern Time (UTC-5) +- `2024-01-15` - Date only (time defaults to 00:00:00) + +The `datetime` field in requests is converted to a `date` field in responses, stored as a timestamp. + +## Use Cases + +References are useful for: + +- **Product Launches**: Mark when new versions or features are released +- **Marketing Campaigns**: Track campaign start and end dates +- **Website Changes**: Note when major redesigns or updates occur +- **Business Events**: Record important business milestones +- **A/B Testing**: Mark when experiments start or end +- **Seasonal Events**: Track holidays, sales periods, or seasonal changes + +## Notes + +- Reference IDs are UUIDs (Universally Unique Identifiers) +- References are scoped to projects - each reference belongs to a specific project +- References are scoped to your organization - you can only manage references for projects in your organization +- The `description` field is optional and can be set to `null` to clear it +- References appear in analytics charts to help correlate metrics with events +- When filtering by `projectId`, the project must exist and belong to your organization diff --git a/apps/public/content/docs/api/meta.json b/apps/public/content/docs/api/meta.json index dc7f2af1..3e49c252 100644 --- a/apps/public/content/docs/api/meta.json +++ b/apps/public/content/docs/api/meta.json @@ -1,4 +1,4 @@ { "title": "API", - "pages": ["track", "export", "insights"] + "pages": ["track", "export", "insights", "manage"] }