From 3d02beda8ed2d927a8b51fe8a4c4ea2b2faa9f79 Mon Sep 17 00:00:00 2001 From: ephraimduncan Date: Wed, 7 Jan 2026 23:30:55 +0000 Subject: [PATCH 1/4] chore --- .gitignore | 2 +- .vscode/settings.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index bd1df71..2588dea 100644 --- a/.gitignore +++ b/.gitignore @@ -169,4 +169,4 @@ formbase-new bun.lockb *.db -.vscode \ No newline at end of file +/.vscode \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 5adaac2..760b886 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,6 +1,6 @@ { "projectColors.mainColor": "#1a6bb7", - "window.title": "shadcn", + "window.title": "formbase", "workbench.colorCustomizations": { "statusBarItem.warningBackground": "#1a6bb7", "statusBarItem.warningForeground": "#ffffff", From e9887c133030fd9088103ee30f0f54edf0a16e3a Mon Sep 17 00:00:00 2001 From: ephraimduncan Date: Thu, 8 Jan 2026 00:45:50 +0000 Subject: [PATCH 2/4] feat: implement public REST API with Bearer token authentication - Add api_keys and api_audit_logs database tables - Implement Bearer token authentication with SHA-256 hashing - Add rate limiting (100 req/min) with X-RateLimit headers - Create REST API endpoints via trpc-to-openapi: - Forms: CRUD, duplicate, bulk operations - Submissions: list with date filter, get, delete, bulk delete - Add OpenAPI 3.0 spec at /api/v1/openapi.json - Add Settings UI for API key management with usage stats - Add audit logging with 90-day retention cleanup cron --- .claude/ralph-loop.local.md | 9 + .claude/settings.local.json | 4 +- API-SPEC.md | 434 +++++++++ apps/web/next-env.d.ts | 2 +- apps/web/package.json | 1 + .../settings/api-keys/api-keys-section.tsx | 321 +++++++ .../dashboard/settings/api-keys/page.tsx | 36 + .../app/(main)/dashboard/settings/layout.tsx | 4 + .../app/api/cron/cleanup-audit-logs/route.ts | 23 + apps/web/src/app/api/v1/[...trpc]/route.ts | 94 ++ apps/web/src/app/api/v1/openapi.json/route.ts | 24 + bun.lock | 48 +- packages/api/index.ts | 5 +- packages/api/lib/api-key.ts | 15 + packages/api/lib/audit-log.ts | 55 ++ packages/api/middleware/api-auth.ts | 41 + packages/api/middleware/rate-limit.ts | 59 ++ packages/api/package.json | 1 + packages/api/routers/api-keys.ts | 95 ++ packages/api/routers/api-keys.ts.bak | 96 ++ packages/api/routers/api-v1/forms.ts | 365 ++++++++ packages/api/routers/api-v1/index.ts | 12 + packages/api/routers/api-v1/schemas.ts | 79 ++ packages/api/routers/api-v1/submissions.ts | 241 +++++ packages/api/routers/api-v1/trpc.ts | 87 ++ packages/db/drizzle/0001_right_menace.sql | 33 + packages/db/drizzle/meta/0001_snapshot.json | 862 ++++++++++++++++++ packages/db/drizzle/meta/_journal.json | 7 + packages/db/index.ts | 9 +- packages/db/schema/api-audit-logs.ts | 37 + packages/db/schema/api-keys.ts | 33 + packages/db/schema/index.ts | 2 + packages/db/schema/relations.ts | 18 + 33 files changed, 3146 insertions(+), 6 deletions(-) create mode 100644 .claude/ralph-loop.local.md create mode 100644 API-SPEC.md create mode 100644 apps/web/src/app/(main)/dashboard/settings/api-keys/api-keys-section.tsx create mode 100644 apps/web/src/app/(main)/dashboard/settings/api-keys/page.tsx create mode 100644 apps/web/src/app/api/cron/cleanup-audit-logs/route.ts create mode 100644 apps/web/src/app/api/v1/[...trpc]/route.ts create mode 100644 apps/web/src/app/api/v1/openapi.json/route.ts create mode 100644 packages/api/lib/api-key.ts create mode 100644 packages/api/lib/audit-log.ts create mode 100644 packages/api/middleware/api-auth.ts create mode 100644 packages/api/middleware/rate-limit.ts create mode 100644 packages/api/routers/api-keys.ts create mode 100644 packages/api/routers/api-keys.ts.bak create mode 100644 packages/api/routers/api-v1/forms.ts create mode 100644 packages/api/routers/api-v1/index.ts create mode 100644 packages/api/routers/api-v1/schemas.ts create mode 100644 packages/api/routers/api-v1/submissions.ts create mode 100644 packages/api/routers/api-v1/trpc.ts create mode 100644 packages/db/drizzle/0001_right_menace.sql create mode 100644 packages/db/drizzle/meta/0001_snapshot.json create mode 100644 packages/db/schema/api-audit-logs.ts create mode 100644 packages/db/schema/api-keys.ts diff --git a/.claude/ralph-loop.local.md b/.claude/ralph-loop.local.md new file mode 100644 index 0000000..7002ff1 --- /dev/null +++ b/.claude/ralph-loop.local.md @@ -0,0 +1,9 @@ +--- +active: true +iteration: 15 +max_iterations: 20 +completion_promise: "DONE" +started_at: "2026-01-07T23:48:59Z" +--- + +Read the @API-SPEC.md and implement it to build an api for the application diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 5cc444a..369379d 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -10,7 +10,9 @@ "Bash(npx @tailwindcss/upgrade --force)", "Bash(xargs:*)", "WebFetch(domain:avvvatars.com)", - "Bash(bun add:*)" + "Bash(bun add:*)", + "mcp__plugin_context7_context7__resolve-library-id", + "mcp__plugin_context7_context7__query-docs" ] } } diff --git a/API-SPEC.md b/API-SPEC.md new file mode 100644 index 0000000..011d938 --- /dev/null +++ b/API-SPEC.md @@ -0,0 +1,434 @@ +# Public REST API Implementation Plan + +## Overview + +Implement a public tRPC API exposed as REST via OpenAPI using `trpc-to-openapi`. The API enables programmatic management of forms and submissions with Bearer token authentication. + +## Key Decisions (from interview) + +| Aspect | Decision | +| --------------------- | ---------------------------------------------------------------------------- | +| API Key Scope | User-level (one key = all user's forms) | +| Multiple Keys | Yes, with optional expiration dates and labels | +| Form Creation | Allowed via API | +| Submissions Filtering | Date range only (inclusive both ends) | +| Delete Behavior | Hard delete | +| Response Data Format | Parsed JSON objects (not raw strings) | +| Rate Limiting | 100 req/min, 429 + Retry-After header | +| Bulk Operations | Full support (create, update, delete) | +| Duplicate Scope | Config only (no submissions) | +| Audit Logs | Full logging, 90-day retention, preserved on form delete | +| Pagination | Offset-based (?page=N&perPage=N) | +| Bulk HTTP Methods | Method matches action (DELETE for delete, POST for create, PATCH for update) | +| OpenAPI Spec | Public at `/api/v1/openapi.json` | +| Webhooks | Not in initial version | +| Form Keys | Read-only exposure | +| Form Settings | Subset only (title, description, returnUrl) | +| Error Format | Standard HTTP: `{ error: { code, message } }` | +| Versioning | URL path (`/api/v1/...`) | +| Settings UI | Add to existing settings page | +| Default Email | Auto-set to API key owner's email | +| Bulk Limit | No limit | +| Caching | No caching headers | +| List Response | Include submission count per form | + +--- + +## Database Schema Changes + +### New Table: `api_keys` + +```typescript +// packages/db/schema/api-keys.ts +export const apiKeys = sqliteTable( + 'api_keys', + { + id: text('id').primaryKey(), + userId: text('user_id') + .notNull() + .references(() => users.id, { onDelete: 'cascade' }), + name: text('name').notNull(), // User-provided label + keyHash: text('key_hash').notNull().unique(), // SHA-256 hash of the key + keyPrefix: text('key_prefix').notNull(), // First 8 chars for display (api_xxx...) + expiresAt: integer('expires_at', { mode: 'timestamp_ms' }), // Optional expiration + lastUsedAt: integer('last_used_at', { mode: 'timestamp_ms' }), + createdAt: integer('created_at', { mode: 'timestamp_ms' }) + .notNull() + .default(sql`(strftime('%s', 'now') * 1000)`), + }, + (table) => ({ + userIdx: index('api_key_user_idx').on(table.userId), + keyHashIdx: index('api_key_hash_idx').on(table.keyHash), + }), +); +``` + +### New Table: `api_audit_logs` + +```typescript +// packages/db/schema/api-audit-logs.ts +export const apiAuditLogs = sqliteTable( + 'api_audit_logs', + { + id: text('id').primaryKey(), + apiKeyId: text('api_key_id') + .notNull() + .references(() => apiKeys.id, { onDelete: 'set null' }), + userId: text('user_id').notNull(), // Denormalized for preservation + method: text('method').notNull(), // GET, POST, DELETE, etc. + path: text('path').notNull(), // /api/v1/forms/xxx + statusCode: integer('status_code').notNull(), // 200, 404, 429, etc. + ipAddress: text('ip_address'), + userAgent: text('user_agent'), + requestBody: text('request_body'), // JSON string (sanitized) + responseTimeMs: integer('response_time_ms'), + createdAt: integer('created_at', { mode: 'timestamp_ms' }) + .notNull() + .default(sql`(strftime('%s', 'now') * 1000)`), + }, + (table) => ({ + apiKeyIdx: index('audit_api_key_idx').on(table.apiKeyId), + userIdx: index('audit_user_idx').on(table.userId), + createdAtIdx: index('audit_created_at_idx').on(table.createdAt), + }), +); +``` + +--- + +## API Endpoints + +### Authentication + +All endpoints require `Authorization: Bearer api_xxxxxxxxxxxxx` header. + +### Forms + +| Method | Path | Description | +| ------ | ---------------------------------- | ------------------------------------------------------- | +| GET | `/api/v1/forms` | List user's forms with pagination and submission counts | +| POST | `/api/v1/forms` | Create a new form | +| GET | `/api/v1/forms/{formId}` | Get a single form | +| PATCH | `/api/v1/forms/{formId}` | Update a form (title, description, returnUrl) | +| DELETE | `/api/v1/forms/{formId}` | Delete a form and all submissions | +| POST | `/api/v1/forms/{formId}/duplicate` | Duplicate a form (config only) | + +### Bulk Forms + +| Method | Path | Description | +| ------ | -------------------- | --------------------- | +| POST | `/api/v1/forms/bulk` | Create multiple forms | +| PATCH | `/api/v1/forms/bulk` | Update multiple forms | +| DELETE | `/api/v1/forms/bulk` | Delete multiple forms | + +### Submissions + +| Method | Path | Description | +| ------ | --------------------------------------------------- | ------------------------------------------------ | +| GET | `/api/v1/forms/{formId}/submissions` | List submissions with pagination and date filter | +| GET | `/api/v1/forms/{formId}/submissions/{submissionId}` | Get a single submission | +| DELETE | `/api/v1/forms/{formId}/submissions/{submissionId}` | Delete a submission | + +### Bulk Submissions + +| Method | Path | Description | +| ------ | ----------------------------------------- | --------------------------- | +| DELETE | `/api/v1/forms/{formId}/submissions/bulk` | Delete multiple submissions | + +### OpenAPI + +| Method | Path | Description | +| ------ | ---------------------- | ---------------------------------- | +| GET | `/api/v1/openapi.json` | OpenAPI 3.0 specification (public) | + +--- + +## Request/Response Schemas + +### List Forms Response + +```json +{ + "forms": [ + { + "id": "abc123", + "title": "Contact Form", + "description": "Website contact form", + "returnUrl": "https://example.com/thanks", + "keys": ["name", "email", "message"], + "submissionCount": 42, + "createdAt": "2024-01-15T10:30:00Z", + "updatedAt": "2024-01-20T14:22:00Z" + } + ], + "pagination": { + "page": 1, + "perPage": 20, + "total": 45, + "totalPages": 3 + } +} +``` + +### Create Form Request + +```json +{ + "title": "New Form", + "description": "Optional description", + "returnUrl": "https://example.com/thanks" +} +``` + +### List Submissions Request + +``` +GET /api/v1/forms/{formId}/submissions?page=1&perPage=50&startDate=2024-01-01&endDate=2024-01-31 +``` + +### List Submissions Response + +```json +{ + "submissions": [ + { + "id": "sub123", + "formId": "form456", + "data": { + "name": "John Doe", + "email": "john@example.com", + "message": "Hello!" + }, + "createdAt": "2024-01-15T10:30:00Z" + } + ], + "pagination": { + "page": 1, + "perPage": 50, + "total": 120, + "totalPages": 3 + } +} +``` + +### Bulk Delete Request + +```json +{ + "ids": ["id1", "id2", "id3"] +} +``` + +### Error Response + +```json +{ + "error": { + "code": "NOT_FOUND", + "message": "Form not found" + } +} +``` + +--- + +## Implementation Structure + +### New Files + +``` +packages/ +├── api/ +│ ├── routers/ +│ │ ├── api-v1/ +│ │ │ ├── index.ts # v1 router combining all sub-routers +│ │ │ ├── forms.ts # Form CRUD + bulk operations +│ │ │ ├── submissions.ts # Submission operations +│ │ │ └── meta.ts # OpenAPI metadata definitions +│ │ └── api-keys.ts # API key management (for settings UI) +│ ├── middleware/ +│ │ ├── api-auth.ts # Bearer token validation +│ │ └── rate-limit.ts # Rate limiting logic +│ └── lib/ +│ ├── api-key.ts # Key generation, hashing utilities +│ └── audit-log.ts # Audit logging helper +├── db/ +│ └── schema/ +│ ├── api-keys.ts # API keys table +│ └── api-audit-logs.ts # Audit logs table + +apps/web/ +├── src/ +│ ├── app/ +│ │ └── api/ +│ │ └── v1/ +│ │ ├── [...trpc]/ +│ │ │ └── route.ts # tRPC-OpenAPI handler +│ │ └── openapi.json/ +│ │ └── route.ts # OpenAPI spec endpoint +│ └── components/ +│ └── settings/ +│ └── api-keys-section.tsx # UI for managing API keys +``` + +### Key Implementation Details + +#### 1. API Key Authentication (`packages/api/middleware/api-auth.ts`) + +```typescript +export async function validateApiKey( + authorization: string | null, + db: Database, +) { + if (!authorization?.startsWith('Bearer ')) { + return null; + } + + const token = authorization.slice(7); + const keyHash = hashApiKey(token); + + const apiKey = await db.query.apiKeys.findFirst({ + where: (table) => + and( + eq(table.keyHash, keyHash), + or(isNull(table.expiresAt), gt(table.expiresAt, new Date())), + ), + with: { user: true }, + }); + + if (apiKey) { + // Update lastUsedAt asynchronously + db.update(apiKeys) + .set({ lastUsedAt: new Date() }) + .where(eq(apiKeys.id, apiKey.id)); + } + + return apiKey; +} +``` + +#### 2. Rate Limiting (`packages/api/middleware/rate-limit.ts`) + +- Use in-memory sliding window counter (or Upstash Redis if available) +- Key: IP address or API key ID +- Limit: 100 requests per minute +- Returns `Retry-After` header with seconds until reset + +#### 3. OpenAPI Metadata Pattern + +```typescript +// packages/api/routers/api-v1/meta.ts +export const listFormsMeta: OpenApiMeta = { + openapi: { + method: 'GET', + path: '/api/v1/forms', + tags: ['Forms'], + summary: 'List all forms', + description: + 'Returns a paginated list of forms owned by the authenticated user.', + }, +}; +``` + +#### 4. tRPC-OpenAPI Handler + +```typescript +// apps/web/src/app/api/v1/[...trpc]/route.ts + +import { createOpenApiNextHandler } from 'trpc-to-openapi'; + +const handler = createOpenApiNextHandler({ + router: apiV1Router, + createContext: createApiContext, + responseMeta: () => ({ + headers: { + 'X-RateLimit-Remaining': '...', + 'X-RateLimit-Reset': '...', + }, + }), + onError: ({ error, path }) => { + logAuditError(error, path); + }, +}); +``` + +#### 5. Audit Log Cleanup Job + +- Run daily via cron or scheduled function +- Delete logs older than 90 days: + +```sql +DELETE FROM api_audit_logs WHERE created_at < (now - 90 days) +``` + +--- + +## Settings UI + +### API Keys Section (add to existing settings page) + +Features: + +- List existing API keys (name, prefix, created date, last used, expiration) +- Create new key (name input, optional expiration date picker) +- Show full key ONCE on creation (modal with copy button) +- Delete key with confirmation +- View usage stats (total requests, last 24h requests from audit logs) + +--- + +## Verification Plan + +1. **Unit Tests** + - API key generation and hashing + - Rate limit logic + - Date range filtering + +2. **Integration Tests** + - Create/list/update/delete forms via REST + - Bulk operations + - Pagination + - Rate limit enforcement + - Invalid/expired key rejection + +3. **Manual Testing** + - Generate OpenAPI spec and validate with Swagger UI + - Test with curl commands + - Test with Postman/Insomnia + - Verify rate limiting with rapid requests + - Test audit log entries + +4. **UI Testing** + - Create API key and verify display + - Copy key functionality + - Delete key functionality + - View usage stats + +--- + +## Dependencies to Add + +```json +{ + "dependencies": { + "trpc-to-openapi": "^2.0.0", + "@upstash/ratelimit": "^2.0.0" // Optional, can use in-memory + } +} +``` + +--- + +## Migration Steps + +1. Create and run database migrations for new tables +2. Implement API key utilities (generate, hash, validate) +3. Implement rate limiting middleware +4. Create v1 tRPC router with OpenAPI metadata +5. Set up REST handler at `/api/v1/[...trpc]` +6. Add OpenAPI spec endpoint +7. Implement audit logging +8. Build settings UI for API key management +9. Add audit log cleanup job +10. Write tests +11. Update documentation diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts index c4b7818..9edff1c 100644 --- a/apps/web/next-env.d.ts +++ b/apps/web/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/dev/types/routes.d.ts"; +import "./.next/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web/package.json b/apps/web/package.json index d5e8bb3..1d976db 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -44,6 +44,7 @@ "server-only": "^0.0.1", "sonner": "^1.4.41", "superjson": "^2.2.1", + "trpc-to-openapi": "^2.0.0", "ts-pattern": "^5.1.2", "zod": "^3.23.8" }, diff --git a/apps/web/src/app/(main)/dashboard/settings/api-keys/api-keys-section.tsx b/apps/web/src/app/(main)/dashboard/settings/api-keys/api-keys-section.tsx new file mode 100644 index 0000000..6545d10 --- /dev/null +++ b/apps/web/src/app/(main)/dashboard/settings/api-keys/api-keys-section.tsx @@ -0,0 +1,321 @@ +'use client'; + +import { useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { format } from 'date-fns'; +import { CalendarIcon, KeyRound, Plus, Trash2 } from 'lucide-react'; +import { useForm } from 'react-hook-form'; +import { toast } from 'sonner'; +import { z } from 'zod'; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@formbase/ui/primitives/alert-dialog'; +import { Button } from '@formbase/ui/primitives/button'; +import { Calendar } from '@formbase/ui/primitives/calendar'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from '@formbase/ui/primitives/dialog'; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@formbase/ui/primitives/form'; +import { Input } from '@formbase/ui/primitives/input'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@formbase/ui/primitives/popover'; + +import { cn } from '@formbase/ui/utils/cn'; + +import { CopyButton } from '~/components/copy-button'; +import { LoadingButton } from '~/components/loading-button'; +import { api } from '~/lib/trpc/react'; + +const createKeySchema = z.object({ + name: z.string().min(1, 'Name is required').max(100), + expiresAt: z.date().optional(), +}); + +type CreateKeyFormValues = z.infer; + +export function ApiKeysSection() { + const [createDialogOpen, setCreateDialogOpen] = useState(false); + const [newKeyValue, setNewKeyValue] = useState(null); + const [deleteKeyId, setDeleteKeyId] = useState(null); + + const utils = api.useUtils(); + const { data: apiKeys, isLoading } = api.apiKeys.list.useQuery(); + + const form = useForm({ + resolver: zodResolver(createKeySchema), + defaultValues: { + name: '', + }, + }); + + const { mutateAsync: createKey, isPending: isCreating } = + api.apiKeys.create.useMutation({ + onSuccess: (data) => { + setNewKeyValue(data.key); + setCreateDialogOpen(false); + form.reset(); + utils.apiKeys.list.invalidate(); + toast.success('API key created'); + }, + }); + + const { mutateAsync: deleteKey, isPending: isDeleting } = + api.apiKeys.delete.useMutation({ + onSuccess: () => { + setDeleteKeyId(null); + utils.apiKeys.list.invalidate(); + toast.success('API key deleted'); + }, + }); + + const handleCreateKey = async (data: CreateKeyFormValues) => { + await createKey({ name: data.name, expiresAt: data.expiresAt }); + }; + + const handleDeleteKey = async () => { + if (deleteKeyId) { + await deleteKey({ id: deleteKeyId }); + } + }; + + if (isLoading) { + return ( +
+
Loading...
+
+ ); + } + + return ( +
+
+

+ API keys allow programmatic access to your forms and submissions. +

+ + + + + + + Create API Key + + Give your API key a name to help you identify it later. + + +
+ + ( + + Name + + + + + + )} + /> + ( + + Expiration (optional) + + + + + + + + date < new Date()} + initialFocus + /> + + + + + )} + /> + + + Create Key + + + + +
+
+
+ + {apiKeys && apiKeys.length > 0 ? ( +
+ {apiKeys.map((apiKey) => ( + setDeleteKeyId(apiKey.id)} + /> + ))} +
+ ) : ( +
+ +

+ No API keys yet. Create one to get started. +

+
+ )} + + setNewKeyValue(null)}> + + + API Key Created + + Copy your API key now. You won't be able to see it again. + + +
+ {newKeyValue} + {newKeyValue && } +
+ + + +
+
+ + !open && setDeleteKeyId(null)} + > + + + Delete API Key + + Are you sure you want to delete this API key? This action cannot + be undone and any applications using this key will stop working. + + + + Cancel + + {isDeleting ? 'Deleting...' : 'Delete'} + + + + +
+ ); +} + +interface ApiKeyRowProps { + apiKey: { + id: string; + name: string; + keyPrefix: string; + createdAt: Date; + lastUsedAt: Date | null; + expiresAt: Date | null; + }; + onDelete: () => void; +} + +function ApiKeyRow({ apiKey, onDelete }: ApiKeyRowProps) { + const isExpired = apiKey.expiresAt && new Date(apiKey.expiresAt) < new Date(); + const { data: usageStats } = api.apiKeys.getUsageStats.useQuery({ id: apiKey.id }); + + return ( +
+
+
+ {apiKey.name} + {isExpired && ( + + Expired + + )} +
+
+ + {apiKey.keyPrefix}... + + Created {format(new Date(apiKey.createdAt), 'MMM d, yyyy')} + {apiKey.lastUsedAt && ( + + Last used {format(new Date(apiKey.lastUsedAt), 'MMM d, yyyy')} + + )} + {apiKey.expiresAt && !isExpired && ( + + Expires {format(new Date(apiKey.expiresAt), 'MMM d, yyyy')} + + )} + {usageStats && ( + + {usageStats.total} requests ({usageStats.last24h} last 24h) + + )} +
+
+ +
+ ); +} diff --git a/apps/web/src/app/(main)/dashboard/settings/api-keys/page.tsx b/apps/web/src/app/(main)/dashboard/settings/api-keys/page.tsx new file mode 100644 index 0000000..1080c91 --- /dev/null +++ b/apps/web/src/app/(main)/dashboard/settings/api-keys/page.tsx @@ -0,0 +1,36 @@ +import { redirect } from 'next/navigation'; + +import type { Metadata } from 'next'; + +import { getSession } from '@formbase/auth/server'; +import { env } from '@formbase/env'; +import { Separator } from '@formbase/ui/primitives/separator'; + +import { ApiKeysSection } from './api-keys-section'; + +export const metadata: Metadata = { + metadataBase: new URL(env.NEXT_PUBLIC_APP_URL), + title: 'API Keys | Formbase', + description: 'Manage your API keys for programmatic access', +}; + +export default async function ApiKeysPage() { + const session = await getSession(); + + if (!session) { + redirect('/login'); + } + + return ( +
+
+

API Keys

+

+ Manage API keys for programmatic access to your forms and submissions. +

+
+ + +
+ ); +} diff --git a/apps/web/src/app/(main)/dashboard/settings/layout.tsx b/apps/web/src/app/(main)/dashboard/settings/layout.tsx index e172d11..31418e6 100644 --- a/apps/web/src/app/(main)/dashboard/settings/layout.tsx +++ b/apps/web/src/app/(main)/dashboard/settings/layout.tsx @@ -12,6 +12,10 @@ const sidebarNavItems = [ title: 'Profile', href: '/dashboard/settings', }, + { + title: 'API Keys', + href: '/dashboard/settings/api-keys', + }, ]; interface SettingsLayoutProps { diff --git a/apps/web/src/app/api/cron/cleanup-audit-logs/route.ts b/apps/web/src/app/api/cron/cleanup-audit-logs/route.ts new file mode 100644 index 0000000..299daa8 --- /dev/null +++ b/apps/web/src/app/api/cron/cleanup-audit-logs/route.ts @@ -0,0 +1,23 @@ +import { db } from '@formbase/db'; +import { cleanupOldAuditLogs } from '@formbase/api/lib/audit-log'; + +export const dynamic = 'force-dynamic'; + +export async function GET(request: Request) { + const authHeader = request.headers.get('authorization'); + + if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) { + return Response.json({ error: 'Unauthorized' }, { status: 401 }); + } + + try { + await cleanupOldAuditLogs(db); + return Response.json({ success: true, message: 'Audit logs cleaned up' }); + } catch (error) { + console.error('Failed to cleanup audit logs:', error); + return Response.json( + { error: 'Failed to cleanup audit logs' }, + { status: 500 }, + ); + } +} diff --git a/apps/web/src/app/api/v1/[...trpc]/route.ts b/apps/web/src/app/api/v1/[...trpc]/route.ts new file mode 100644 index 0000000..47502bf --- /dev/null +++ b/apps/web/src/app/api/v1/[...trpc]/route.ts @@ -0,0 +1,94 @@ +import { type NextRequest, NextResponse } from 'next/server'; +import { createOpenApiFetchHandler } from 'trpc-to-openapi'; + +import { apiV1Router, createApiV1Context, type ApiV1Context } from '@formbase/api/routers/api-v1'; +import { logApiRequest } from '@formbase/api'; +import { db } from '@formbase/db'; + +export const dynamic = 'force-dynamic'; + +async function transformErrorResponse(response: Response): Promise { + if (response.ok) return response; + + try { + const body = await response.json(); + const errorResponse = { + error: { + code: body.code ?? 'INTERNAL_SERVER_ERROR', + message: body.message ?? 'An unexpected error occurred', + }, + }; + + const headers = new Headers(response.headers); + + if (response.status === 429 && body.message) { + const match = body.message.match(/Retry after (\d+) seconds/); + if (match) { + headers.set('Retry-After', match[1]); + } + } + + return new NextResponse(JSON.stringify(errorResponse), { + status: response.status, + headers, + }); + } catch { + return response; + } +} + +const handler = async (req: NextRequest) => { + const startTime = Date.now(); + let ctx: ApiV1Context | null = null; + + const response = await createOpenApiFetchHandler({ + endpoint: '/api/v1', + router: apiV1Router, + createContext: async () => { + ctx = await createApiV1Context({ headers: req.headers }); + return ctx; + }, + req, + responseMeta: ({ ctx: responseCtx }) => { + const headers: Record = {}; + const typedCtx = responseCtx as ApiV1Context | undefined; + + if (typedCtx?.rateLimitRemaining !== undefined) { + headers['X-RateLimit-Remaining'] = String(typedCtx.rateLimitRemaining); + } + if (typedCtx?.rateLimitReset !== undefined) { + headers['X-RateLimit-Reset'] = String(typedCtx.rateLimitReset); + } + + return { headers }; + }, + }); + + const responseTime = Date.now() - startTime; + const typedCtx = ctx as ApiV1Context | null; + + if (typedCtx?.apiKey) { + logApiRequest(db, { + apiKeyId: typedCtx.apiKey.id, + userId: typedCtx.apiKey.userId, + method: req.method, + path: new URL(req.url).pathname, + statusCode: response.status, + ipAddress: req.headers.get('x-forwarded-for') ?? req.headers.get('x-real-ip') ?? undefined, + userAgent: req.headers.get('user-agent') ?? undefined, + responseTimeMs: responseTime, + }).catch(() => {}); + } + + return transformErrorResponse(response); +}; + +export { + handler as GET, + handler as POST, + handler as PUT, + handler as PATCH, + handler as DELETE, + handler as OPTIONS, + handler as HEAD, +}; diff --git a/apps/web/src/app/api/v1/openapi.json/route.ts b/apps/web/src/app/api/v1/openapi.json/route.ts new file mode 100644 index 0000000..dc18a9e --- /dev/null +++ b/apps/web/src/app/api/v1/openapi.json/route.ts @@ -0,0 +1,24 @@ +import { generateOpenApiDocument } from 'trpc-to-openapi'; + +import { env } from '@formbase/env'; +import { apiV1Router } from '@formbase/api/routers/api-v1'; + +export const dynamic = 'force-dynamic'; + +export async function GET() { + const openApiDocument = generateOpenApiDocument(apiV1Router, { + title: 'Formbase API', + version: '1.0.0', + baseUrl: `${env.NEXT_PUBLIC_APP_URL}/api/v1`, + description: 'Public REST API for managing forms and submissions.', + securitySchemes: { + bearerAuth: { + type: 'http', + scheme: 'bearer', + description: 'API key in format: Bearer api_xxxxxxxxxxxxx', + }, + }, + }); + + return Response.json(openApiDocument); +} diff --git a/bun.lock b/bun.lock index 8938a7c..3c26c69 100644 --- a/bun.lock +++ b/bun.lock @@ -78,6 +78,7 @@ "server-only": "^0.0.1", "sonner": "^1.4.41", "superjson": "^2.2.1", + "trpc-to-openapi": "^2.0.0", "ts-pattern": "^5.1.2", "zod": "^3.23.8", }, @@ -105,6 +106,7 @@ "@formbase/utils": "workspace:*", "@trpc/server": "next", "superjson": "^2.2.1", + "trpc-to-openapi": "^2.0.0", "zod": "^3.23.8", }, "devDependencies": { @@ -541,6 +543,8 @@ "@formbase/web": ["@formbase/web@workspace:apps/web"], + "@hapi/bourne": ["@hapi/bourne@3.0.0", "", {}, "sha512-Waj1cwPXJDucOib4a3bAISsKJVb15MKi9IvmTI/7ssVEm6sywXGjVJDhl6/umt1pK1ZS7PacXU3A1PmFKHEZ2w=="], + "@hookform/resolvers": ["@hookform/resolvers@3.10.0", "", { "peerDependencies": { "react-hook-form": "^7.0.0" } }, "sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag=="], "@humanwhocodes/config-array": ["@humanwhocodes/config-array@0.13.0", "", { "dependencies": { "@humanwhocodes/object-schema": "^2.0.3", "debug": "^4.3.1", "minimatch": "^3.0.5" } }, "sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw=="], @@ -817,7 +821,7 @@ "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.55.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-/0PenBCmqM4ZUd0190j7J0UsQ/1nsi735iPRakO8iPciE7BQ495Y6msPzaOmvx0/pn+eJVVlZrNrSh4WSYLxNg=="], - "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.55.1", "", { "os": "linux", "cpu": "x64" }, "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg=="], + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.6.1", "", { "os": "linux", "cpu": "x64" }, "sha512-DNGZvZDO5YF7jN5fX8ZqmGLjZEXIJRdJEdTFMhiyXqyXubBa0WVLDWSNlQ5JR2PNgDbEV1VQowhVRUh+74D+RA=="], "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.55.1", "", { "os": "linux", "cpu": "x64" }, "sha512-bD+zjpFrMpP/hqkfEcnjXWHMw5BIghGisOKPj+2NaNDuVT+8Ds4mPf3XcPHuat1tz89WRL+1wbcxKY3WSbiT7w=="], @@ -1287,6 +1291,8 @@ "bundle-require": ["bundle-require@5.1.0", "", { "dependencies": { "load-tsconfig": "^0.2.3" }, "peerDependencies": { "esbuild": ">=0.18" } }, "sha512-3WrrOuZiyaaZPWiEt4G3+IffISVC9HYlWueJEBWED4ZH4aIAC2PnkdnuRrR94M+w6yGWn4AglWtJtBI8YqvgoA=="], + "bytes": ["bytes@3.1.2", "", {}, "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg=="], + "cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="], "call-bind": ["call-bind@1.0.8", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.0", "es-define-property": "^1.0.0", "get-intrinsic": "^1.2.4", "set-function-length": "^1.2.2" } }, "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww=="], @@ -1337,6 +1343,8 @@ "cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="], + "co-body": ["co-body@6.2.0", "", { "dependencies": { "@hapi/bourne": "^3.0.0", "inflation": "^2.0.0", "qs": "^6.5.2", "raw-body": "^2.3.3", "type-is": "^1.6.16" } }, "sha512-Kbpv2Yd1NdL1V/V4cwLVxraHDV6K8ayohr2rmH0J87Er8+zJjcTa6dAn9QMPC9CRgU8+aNajKbSf1TzDB1yKPA=="], + "collapse-white-space": ["collapse-white-space@2.1.0", "", {}, "sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw=="], "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], @@ -1439,6 +1447,8 @@ "defu": ["defu@6.1.4", "", {}, "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg=="], + "depd": ["depd@2.0.0", "", {}, "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw=="], + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], "destr": ["destr@2.0.5", "", {}, "sha512-ugFTXCtDZunbzasqBxrK93Ik/DRYsO6S/fedkWEMKqt04xZ4csmnmwGDBAb07QWNaGMAmnTIemsYZCksjATwsA=="], @@ -1729,7 +1739,7 @@ "graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="], - "h3": ["h3@1.15.4", "", { "dependencies": { "cookie-es": "^1.2.2", "crossws": "^0.3.5", "defu": "^6.1.4", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.2", "radix3": "^1.1.2", "ufo": "^1.6.1", "uncrypto": "^0.1.3" } }, "sha512-z5cFQWDffyOe4vQ9xIqNfCZdV4p//vy6fBnr8Q1AWnVZ0teurKMG66rLj++TKwKPUP3u7iMUvrvKaEUiQw2QWQ=="], + "h3": ["h3@1.15.1", "", { "dependencies": { "cookie-es": "^1.2.2", "crossws": "^0.3.3", "defu": "^6.1.4", "destr": "^2.0.3", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.0", "radix3": "^1.1.2", "ufo": "^1.5.4", "uncrypto": "^0.1.3" } }, "sha512-+ORaOBttdUm1E2Uu/obAyCguiI7MbBvsLTndc3gyK3zU+SYLoZXlyCP9Xgy0gikkGufFLTZXCXD6+4BsufnmHA=="], "hanji": ["hanji@0.0.5", "", { "dependencies": { "lodash.throttle": "^4.1.1", "sisteransi": "^1.0.5" } }, "sha512-Abxw1Lq+TnYiL4BueXqMau222fPSPMFtya8HdpWsz/xVAhifXou71mPh/kY2+08RgFcVccjG3uZHs6K5HAe3zw=="], @@ -1787,8 +1797,12 @@ "http-cache-semantics": ["http-cache-semantics@4.2.0", "", {}, "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ=="], + "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], + "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="], + "iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], @@ -1799,6 +1813,8 @@ "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + "inflation": ["inflation@2.1.0", "", {}, "sha512-t54PPJHG1Pp7VQvxyVCJ9mBbjG3Hqryges9bXoOO6GExCPa+//i/d5GSuFtpx3ALLd7lgIAur6zrIlBQyJuMlQ=="], + "inflight": ["inflight@1.0.6", "", { "dependencies": { "once": "^1.3.0", "wrappy": "1" } }, "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA=="], "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], @@ -2055,6 +2071,8 @@ "mdn-data": ["mdn-data@2.12.2", "", {}, "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA=="], + "media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="], + "memoizee": ["memoizee@0.4.17", "", { "dependencies": { "d": "^1.0.2", "es5-ext": "^0.10.64", "es6-weak-map": "^2.0.3", "event-emitter": "^0.3.5", "is-promise": "^2.2.2", "lru-queue": "^0.1.0", "next-tick": "^1.1.0", "timers-ext": "^0.1.7" } }, "sha512-DGqD7Hjpi/1or4F/aYAspXKNm5Yili0QDAFAY4QYvpqpgiY6+1jOfqpmByzjxbWd/T9mChbCArXAbDAsTm5oXA=="], "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], @@ -2229,6 +2247,8 @@ "oniguruma-to-es": ["oniguruma-to-es@2.3.0", "", { "dependencies": { "emoji-regex-xs": "^1.0.0", "regex": "^5.1.1", "regex-recursion": "^5.1.1" } }, "sha512-bwALDxriqfKGfUufKGGepCzu9x7nJQuoRoAFp4AnwehhC2crqrDIAP/uN2qdlsAvSMpeRC3+Yzhqc7hLmle5+g=="], + "openapi3-ts": ["openapi3-ts@4.4.0", "", { "dependencies": { "yaml": "^2.5.0" } }, "sha512-9asTNB9IkKEzWMcHmVZE7Ts3kC9G7AFHfs8i7caD8HbI76gEjdkId4z/AkP83xdZsH7PLAnnbl47qZkXuxpArw=="], + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], "own-keys": ["own-keys@1.0.1", "", { "dependencies": { "get-intrinsic": "^1.2.6", "object-keys": "^1.1.1", "safe-push-apply": "^1.0.0" } }, "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg=="], @@ -2335,12 +2355,16 @@ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "qs": ["qs@6.14.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ=="], + "query-string": ["query-string@7.1.3", "", { "dependencies": { "decode-uri-component": "^0.2.2", "filter-obj": "^1.1.0", "split-on-first": "^1.0.0", "strict-uri-encode": "^2.0.0" } }, "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg=="], "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], "radix3": ["radix3@1.1.2", "", {}, "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA=="], + "raw-body": ["raw-body@2.5.3", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.4.24", "unpipe": "~1.0.0" } }, "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA=="], + "react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], "react-day-picker": ["react-day-picker@9.13.0", "", { "dependencies": { "@date-fns/tz": "^1.4.1", "date-fns": "^4.1.0", "date-fns-jalali": "^4.1.0-0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-euzj5Hlq+lOHqI53NiuNhCP8HWgsPf/bBAVijR50hNaY1XwjKjShAnIe8jm8RD2W9IJUvihDIZ+KrmqfFzNhFQ=="], @@ -2461,6 +2485,8 @@ "safe-regex-test": ["safe-regex-test@1.1.0", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "is-regex": "^1.2.1" } }, "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw=="], + "safer-buffer": ["safer-buffer@2.1.2", "", {}, "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="], + "sass-formatter": ["sass-formatter@0.7.9", "", { "dependencies": { "suf-log": "^2.5.3" } }, "sha512-CWZ8XiSim+fJVG0cFLStwDvft1VI7uvXdCNJYXhDvowiv+DsbD1nXLiQ4zrE5UBvj5DWZJ93cwN0NX5PMsr1Pw=="], "sax": ["sax@1.4.3", "", {}, "sha512-yqYn1JhPczigF94DMS+shiDMjDowYO6y9+wB/4WgO0Y19jWYk0lQ4tuG5KI7kj4FTp1wxPj5IFfcrz/s1c3jjQ=="], @@ -2481,6 +2507,8 @@ "set-proto": ["set-proto@1.0.0", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.0.0" } }, "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw=="], + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], + "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], @@ -2525,6 +2553,8 @@ "stable-hash": ["stable-hash@0.0.5", "", {}, "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA=="], + "statuses": ["statuses@2.0.2", "", {}, "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw=="], + "stop-iteration-iterator": ["stop-iteration-iterator@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "internal-slot": "^1.1.0" } }, "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ=="], "stream-chain": ["stream-chain@2.2.5", "", {}, "sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA=="], @@ -2617,6 +2647,8 @@ "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + "toidentifier": ["toidentifier@1.0.1", "", {}, "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA=="], + "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], "tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="], @@ -2625,6 +2657,8 @@ "trough": ["trough@2.2.0", "", {}, "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw=="], + "trpc-to-openapi": ["trpc-to-openapi@2.4.0", "", { "dependencies": { "co-body": "6.2.0", "h3": "1.15.1", "openapi3-ts": "4.4.0" }, "optionalDependencies": { "@rollup/rollup-linux-x64-gnu": "4.6.1" }, "peerDependencies": { "@trpc/server": "^11.1.0", "zod": "^3.23.8", "zod-openapi": "4.2.4" } }, "sha512-B6xrwOC3Ab0q1BWD/QbJzK4OUpCLoT02hAzshSUXEuIZGcJZkMG/OJ4/3gd20dyr8aI+CrFirpWKRIo7JmHbMQ=="], + "ts-api-utils": ["ts-api-utils@1.4.3", "", { "peerDependencies": { "typescript": ">=4.2.0" } }, "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw=="], "ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="], @@ -2663,6 +2697,8 @@ "type-fest": ["type-fest@0.20.2", "", {}, "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ=="], + "type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="], + "typed-array-buffer": ["typed-array-buffer@1.0.3", "", { "dependencies": { "call-bound": "^1.0.3", "es-errors": "^1.3.0", "is-typed-array": "^1.1.14" } }, "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw=="], "typed-array-byte-length": ["typed-array-byte-length@1.0.3", "", { "dependencies": { "call-bind": "^1.0.8", "for-each": "^0.3.3", "gopd": "^1.2.0", "has-proto": "^1.2.0", "is-typed-array": "^1.1.14" } }, "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg=="], @@ -2719,6 +2755,8 @@ "universalify": ["universalify@2.0.1", "", {}, "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw=="], + "unpipe": ["unpipe@1.0.0", "", {}, "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ=="], + "unrs-resolver": ["unrs-resolver@1.11.1", "", { "dependencies": { "napi-postinstall": "^0.3.0" }, "optionalDependencies": { "@unrs/resolver-binding-android-arm-eabi": "1.11.1", "@unrs/resolver-binding-android-arm64": "1.11.1", "@unrs/resolver-binding-darwin-arm64": "1.11.1", "@unrs/resolver-binding-darwin-x64": "1.11.1", "@unrs/resolver-binding-freebsd-x64": "1.11.1", "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", "@unrs/resolver-binding-linux-x64-musl": "1.11.1", "@unrs/resolver-binding-wasm32-wasi": "1.11.1", "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" } }, "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg=="], "unstorage": ["unstorage@1.17.3", "", { "dependencies": { "anymatch": "^3.1.3", "chokidar": "^4.0.3", "destr": "^2.0.5", "h3": "^1.15.4", "lru-cache": "^10.4.3", "node-fetch-native": "^1.6.7", "ofetch": "^1.5.1", "ufo": "^1.6.1" }, "peerDependencies": { "@azure/app-configuration": "^1.8.0", "@azure/cosmos": "^4.2.0", "@azure/data-tables": "^13.3.0", "@azure/identity": "^4.6.0", "@azure/keyvault-secrets": "^4.9.0", "@azure/storage-blob": "^12.26.0", "@capacitor/preferences": "^6.0.3 || ^7.0.0", "@deno/kv": ">=0.9.0", "@netlify/blobs": "^6.5.0 || ^7.0.0 || ^8.1.0 || ^9.0.0 || ^10.0.0", "@planetscale/database": "^1.19.0", "@upstash/redis": "^1.34.3", "@vercel/blob": ">=0.27.1", "@vercel/functions": "^2.2.12 || ^3.0.0", "@vercel/kv": "^1.0.1", "aws4fetch": "^1.0.20", "db0": ">=0.2.1", "idb-keyval": "^6.2.1", "ioredis": "^5.4.2", "uploadthing": "^7.4.4" }, "optionalPeers": ["@azure/app-configuration", "@azure/cosmos", "@azure/data-tables", "@azure/identity", "@azure/keyvault-secrets", "@azure/storage-blob", "@capacitor/preferences", "@deno/kv", "@netlify/blobs", "@planetscale/database", "@upstash/redis", "@vercel/blob", "@vercel/functions", "@vercel/kv", "aws4fetch", "db0", "idb-keyval", "ioredis", "uploadthing"] }, "sha512-i+JYyy0DoKmQ3FximTHbGadmIYb8JEpq7lxUjnjeB702bCPum0vzo6oy5Mfu0lpqISw7hCyMW2yj4nWC8bqJ3Q=="], @@ -2847,6 +2885,8 @@ "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "zod-openapi": ["zod-openapi@4.2.4", "", { "peerDependencies": { "zod": "^3.21.4" } }, "sha512-tsrQpbpqFCXqVXUzi3TPwFhuMtLN3oNZobOtYnK6/5VkXsNdnIgyNr4r8no4wmYluaxzN3F7iS+8xCW8BmMQ8g=="], + "zod-to-json-schema": ["zod-to-json-schema@3.25.1", "", { "peerDependencies": { "zod": "^3.25 || ^4" } }, "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA=="], "zod-to-ts": ["zod-to-ts@1.2.0", "", { "peerDependencies": { "typescript": "^4.9.4 || ^5.0.2", "zod": "^3" } }, "sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA=="], @@ -3077,6 +3117,8 @@ "resend/@react-email/render": ["@react-email/render@0.0.16", "", { "dependencies": { "html-to-text": "9.0.5", "js-beautify": "^1.14.11", "react-promise-suspense": "0.3.4" }, "peerDependencies": { "react": "^18.2.0", "react-dom": "^18.2.0" } }, "sha512-wDaMy27xAq1cJHtSFptp0DTKPuV2GYhloqia95ub/DH9Dea1aWYsbdM918MOc/b/HvVS3w1z8DWzfAk13bGStQ=="], + "rollup/@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.55.1", "", { "os": "linux", "cpu": "x64" }, "sha512-a8G4wiQxQG2BAvo+gU6XrReRRqj+pLS2NGXKm8io19goR+K8lw269eTrPkSdDTALwMmJp4th2Uh0D8J9bEV1vg=="], + "string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], @@ -3101,6 +3143,8 @@ "typescript-eslint/@typescript-eslint/utils": ["@typescript-eslint/utils@8.52.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.52.0", "@typescript-eslint/types": "8.52.0", "@typescript-eslint/typescript-estree": "8.52.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-wYndVMWkweqHpEpwPhwqE2lnD2DxC6WVLupU/DOt/0/v+/+iQbbzO3jOHjmBMnhu0DgLULvOaU4h4pwHYi2oRQ=="], + "unstorage/h3": ["h3@1.15.4", "", { "dependencies": { "cookie-es": "^1.2.2", "crossws": "^0.3.5", "defu": "^6.1.4", "destr": "^2.0.5", "iron-webcrypto": "^1.2.1", "node-mock-http": "^1.0.2", "radix3": "^1.1.2", "ufo": "^1.6.1", "uncrypto": "^0.1.3" } }, "sha512-z5cFQWDffyOe4vQ9xIqNfCZdV4p//vy6fBnr8Q1AWnVZ0teurKMG66rLj++TKwKPUP3u7iMUvrvKaEUiQw2QWQ=="], + "unstorage/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], "vite/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], diff --git a/packages/api/index.ts b/packages/api/index.ts index 9119dd1..1e5ccca 100644 --- a/packages/api/index.ts +++ b/packages/api/index.ts @@ -1,5 +1,7 @@ import { type inferRouterInputs, type inferRouterOutputs } from '@trpc/server'; +import { apiKeysRouter } from './routers/api-keys'; +import { authRouter } from './routers/auth'; import { formRouter } from './routers/form'; import { formDataRouter } from './routers/formData'; import { userRouter } from './routers/user'; @@ -10,6 +12,7 @@ export const appRouter = createRouter({ user: userRouter, form: formRouter, formData: formDataRouter, + apiKeys: apiKeysRouter, }); export type AppRouter = typeof appRouter; @@ -19,4 +22,4 @@ export type RouterOutputs = inferRouterOutputs; export const createCaller = createCallerFactory(appRouter); export { createTRPCContext } from './trpc'; -import { authRouter } from './routers/auth'; +export { logApiRequest, cleanupOldAuditLogs } from './lib/audit-log'; diff --git a/packages/api/lib/api-key.ts b/packages/api/lib/api-key.ts new file mode 100644 index 0000000..0bb52dc --- /dev/null +++ b/packages/api/lib/api-key.ts @@ -0,0 +1,15 @@ +import { createHash, randomBytes } from 'crypto'; + +const API_KEY_PREFIX = 'api_'; + +export function generateApiKey(): { key: string; prefix: string; hash: string } { + const randomPart = randomBytes(24).toString('base64url'); + const key = `${API_KEY_PREFIX}${randomPart}`; + const prefix = key.slice(0, 12); + const hash = hashApiKey(key); + return { key, prefix, hash }; +} + +export function hashApiKey(key: string): string { + return createHash('sha256').update(key).digest('hex'); +} diff --git a/packages/api/lib/audit-log.ts b/packages/api/lib/audit-log.ts new file mode 100644 index 0000000..1f6b255 --- /dev/null +++ b/packages/api/lib/audit-log.ts @@ -0,0 +1,55 @@ +import type { LibSQLDatabase } from 'drizzle-orm/libsql'; + +import { drizzlePrimitives } from '@formbase/db'; +import { apiAuditLogs } from '@formbase/db/schema'; +import { generateId } from '@formbase/utils/generate-id'; + +type Database = LibSQLDatabase>; + +interface AuditLogParams { + apiKeyId: string | null; + userId: string; + method: string; + path: string; + statusCode: number; + ipAddress?: string; + userAgent?: string; + requestBody?: unknown; + responseTimeMs?: number; +} + +export async function logApiRequest(db: Database, params: AuditLogParams) { + await db.insert(apiAuditLogs).values({ + id: generateId(15), + apiKeyId: params.apiKeyId, + userId: params.userId, + method: params.method, + path: params.path, + statusCode: params.statusCode, + ipAddress: params.ipAddress ?? null, + userAgent: params.userAgent ?? null, + requestBody: sanitizeRequestBody(params.requestBody), + responseTimeMs: params.responseTimeMs ?? null, + }); +} + +function sanitizeRequestBody(body: unknown): string | null { + if (!body) return null; + if (typeof body !== 'object') return JSON.stringify(body); + + const sanitized = { ...(body as Record) }; + const sensitiveKeys = ['password', 'token', 'secret', 'apiKey', 'authorization']; + + for (const key of sensitiveKeys) { + if (key in sanitized) { + sanitized[key] = '[REDACTED]'; + } + } + + return JSON.stringify(sanitized); +} + +export async function cleanupOldAuditLogs(db: Database) { + const ninetyDaysAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000); + await db.delete(apiAuditLogs).where(drizzlePrimitives.lt(apiAuditLogs.createdAt, ninetyDaysAgo)); +} diff --git a/packages/api/middleware/api-auth.ts b/packages/api/middleware/api-auth.ts new file mode 100644 index 0000000..f44de42 --- /dev/null +++ b/packages/api/middleware/api-auth.ts @@ -0,0 +1,41 @@ +import type { LibSQLDatabase } from 'drizzle-orm/libsql'; + +import { drizzlePrimitives } from '@formbase/db'; +import { apiKeys } from '@formbase/db/schema'; + +const { and, eq, gt, isNull, or } = drizzlePrimitives; + +import { hashApiKey } from '../lib/api-key'; + +type Database = LibSQLDatabase>; + +export async function validateApiKey( + authorization: string | null | undefined, + db: Database, +) { + if (!authorization?.startsWith('Bearer ')) { + return null; + } + + const token = authorization.slice(7); + const keyHash = hashApiKey(token); + + const apiKey = await db.query.apiKeys.findFirst({ + where: (table) => + and( + eq(table.keyHash, keyHash), + or(isNull(table.expiresAt), gt(table.expiresAt, new Date())), + ), + with: { user: true }, + }); + + if (apiKey) { + db.update(apiKeys) + .set({ lastUsedAt: new Date() }) + .where(eq(apiKeys.id, apiKey.id)) + .then(() => {}) + .catch(() => {}); + } + + return apiKey; +} diff --git a/packages/api/middleware/rate-limit.ts b/packages/api/middleware/rate-limit.ts new file mode 100644 index 0000000..e6fb2eb --- /dev/null +++ b/packages/api/middleware/rate-limit.ts @@ -0,0 +1,59 @@ +const WINDOW_MS = 60 * 1000; +const MAX_REQUESTS = 100; + +interface RateLimitEntry { + count: number; + windowStart: number; +} + +const requestCounts = new Map(); + +export interface RateLimitResult { + allowed: boolean; + remaining: number; + resetAt: Date; + retryAfterSeconds?: number; +} + +export function checkRateLimit(keyId: string): RateLimitResult { + const now = Date.now(); + const entry = requestCounts.get(keyId); + + if (!entry || now - entry.windowStart >= WINDOW_MS) { + requestCounts.set(keyId, { count: 1, windowStart: now }); + return { + allowed: true, + remaining: MAX_REQUESTS - 1, + resetAt: new Date(now + WINDOW_MS), + }; + } + + const remaining = MAX_REQUESTS - entry.count - 1; + const resetAt = new Date(entry.windowStart + WINDOW_MS); + + if (entry.count >= MAX_REQUESTS) { + const retryAfterSeconds = Math.ceil((entry.windowStart + WINDOW_MS - now) / 1000); + return { + allowed: false, + remaining: 0, + resetAt, + retryAfterSeconds, + }; + } + + entry.count++; + return { + allowed: true, + remaining: Math.max(0, remaining), + resetAt, + }; +} + +setInterval(() => { + const now = Date.now(); + for (const [key, entry] of requestCounts.entries()) { + if (now - entry.windowStart >= WINDOW_MS * 2) { + requestCounts.delete(key); + } + } +}, WINDOW_MS); diff --git a/packages/api/package.json b/packages/api/package.json index 306e9fb..73eb093 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -14,6 +14,7 @@ "@formbase/utils": "workspace:*", "@trpc/server": "next", "superjson": "^2.2.1", + "trpc-to-openapi": "^2.0.0", "zod": "^3.23.8" }, "devDependencies": { diff --git a/packages/api/routers/api-keys.ts b/packages/api/routers/api-keys.ts new file mode 100644 index 0000000..154ad34 --- /dev/null +++ b/packages/api/routers/api-keys.ts @@ -0,0 +1,95 @@ +import { TRPCError } from '@trpc/server'; +import { z } from 'zod'; + +import { drizzlePrimitives } from '@formbase/db'; +import { apiAuditLogs, apiKeys } from '@formbase/db/schema'; +import { generateId } from '@formbase/utils/generate-id'; + +import { generateApiKey } from '../lib/api-key'; +import { createRouter, protectedProcedure } from '../trpc'; + +const { and, eq, gte } = drizzlePrimitives; + +export const apiKeysRouter = createRouter({ + list: protectedProcedure.query(async ({ ctx }) => { + const keys = await ctx.db.query.apiKeys.findMany({ + where: (table) => eq(table.userId, ctx.user.id), + orderBy: (table, { desc }) => desc(table.createdAt), + }); + return keys; + }), + + create: protectedProcedure + .input( + z.object({ + name: z.string().min(1).max(100), + expiresAt: z.date().optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + const { key, prefix, hash } = generateApiKey(); + + await ctx.db.insert(apiKeys).values({ + id: generateId(15), + userId: ctx.user.id, + name: input.name, + keyHash: hash, + keyPrefix: prefix, + expiresAt: input.expiresAt ?? null, + }); + + return { key }; + }), + + delete: protectedProcedure + .input(z.object({ id: z.string() })) + .mutation(async ({ ctx, input }) => { + const apiKey = await ctx.db.query.apiKeys.findFirst({ + where: (table) => + and(eq(table.id, input.id), eq(table.userId, ctx.user.id)), + }); + + if (!apiKey) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'API key not found' }); + } + + await ctx.db.delete(apiKeys).where(eq(apiKeys.id, input.id)); + }), + + getUsageStats: protectedProcedure + .input(z.object({ id: z.string() })) + .query(async ({ ctx, input }) => { + const apiKey = await ctx.db.query.apiKeys.findFirst({ + where: (table) => + and(eq(table.id, input.id), eq(table.userId, ctx.user.id)), + }); + + if (!apiKey) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'API key not found' }); + } + + const now = new Date(); + const last24h = new Date(now.getTime() - 24 * 60 * 60 * 1000); + + const [totalResult, last24hResult] = await Promise.all([ + ctx.db + .select({ count: drizzlePrimitives.count() }) + .from(apiAuditLogs) + .where(eq(apiAuditLogs.apiKeyId, input.id)), + ctx.db + .select({ count: drizzlePrimitives.count() }) + .from(apiAuditLogs) + .where( + and( + eq(apiAuditLogs.apiKeyId, input.id), + gte(apiAuditLogs.createdAt, last24h), + ), + ), + ]); + + return { + total: totalResult[0]?.count ?? 0, + last24h: last24hResult[0]?.count ?? 0, + }; + }), +}); diff --git a/packages/api/routers/api-keys.ts.bak b/packages/api/routers/api-keys.ts.bak new file mode 100644 index 0000000..82223f9 --- /dev/null +++ b/packages/api/routers/api-keys.ts.bak @@ -0,0 +1,96 @@ +import { TRPCError } from '@trpc/server'; +import { and, desc, eq, gte } from 'drizzle-orm'; +import { z } from 'zod'; + +import { drizzlePrimitives } from '@formbase/db'; +import { apiAuditLogs, apiKeys } from '@formbase/db/schema'; +import { generateId } from '@formbase/utils/generate-id'; + +import { generateApiKey, hashApiKey } from '../lib/api-key'; +import { createRouter, protectedProcedure } from '../trpc'; + +export const apiKeysRouter = createRouter({ + list: protectedProcedure.query(async ({ ctx }) => { + const keys = await ctx.db.query.apiKeys.findMany({ + where: (table) => eq(table.userId, ctx.user.id), + orderBy: (table) => desc(table.createdAt), + }); + return keys; + }), + + create: protectedProcedure + .input( + z.object({ + name: z.string().min(1).max(100), + expiresAt: z.date().optional(), + }), + ) + .mutation(async ({ ctx, input }) => { + const { key, prefix, hash } = generateApiKey(); + + await ctx.db.insert(apiKeys).values({ + id: generateId(15), + userId: ctx.user.id, + name: input.name, + keyHash: hash, + keyPrefix: prefix, + expiresAt: input.expiresAt ?? null, + }); + + return { key }; + }), + + delete: protectedProcedure + .input(z.object({ id: z.string() })) + .mutation(async ({ ctx, input }) => { + const apiKey = await ctx.db.query.apiKeys.findFirst({ + where: (table) => + and(eq(table.id, input.id), eq(table.userId, ctx.user.id)), + }); + + if (!apiKey) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'API key not found' }); + } + + await ctx.db + .delete(apiKeys) + .where(drizzlePrimitives.eq(apiKeys.id, input.id)); + }), + + getUsageStats: protectedProcedure + .input(z.object({ id: z.string() })) + .query(async ({ ctx, input }) => { + const apiKey = await ctx.db.query.apiKeys.findFirst({ + where: (table) => + and(eq(table.id, input.id), eq(table.userId, ctx.user.id)), + }); + + if (!apiKey) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'API key not found' }); + } + + const now = new Date(); + const last24h = new Date(now.getTime() - 24 * 60 * 60 * 1000); + + const [totalResult, last24hResult] = await Promise.all([ + ctx.db + .select({ count: drizzlePrimitives.count() }) + .from(apiAuditLogs) + .where(eq(apiAuditLogs.apiKeyId, input.id)), + ctx.db + .select({ count: drizzlePrimitives.count() }) + .from(apiAuditLogs) + .where( + and( + eq(apiAuditLogs.apiKeyId, input.id), + gte(apiAuditLogs.createdAt, last24h), + ), + ), + ]); + + return { + total: totalResult[0]?.count ?? 0, + last24h: last24hResult[0]?.count ?? 0, + }; + }), +}); diff --git a/packages/api/routers/api-v1/forms.ts b/packages/api/routers/api-v1/forms.ts new file mode 100644 index 0000000..4341b07 --- /dev/null +++ b/packages/api/routers/api-v1/forms.ts @@ -0,0 +1,365 @@ +import { TRPCError } from '@trpc/server'; +import { z } from 'zod'; + +import { drizzlePrimitives } from '@formbase/db'; +import { forms } from '@formbase/db/schema'; + +const { and, count, eq, inArray } = drizzlePrimitives; +import { generateId } from '@formbase/utils/generate-id'; + +import { parseJsonArray, serializeJson } from '../../utils/json'; +import { + bulkCreateFormInputSchema, + bulkDeleteInputSchema, + bulkUpdateFormInputSchema, + createFormInputSchema, + paginationInputSchema, + paginationOutputSchema, + updateFormInputSchema, +} from './schemas'; +import { apiKeyProcedure, createApiV1Router } from './trpc'; + +async function assertApiFormOwnership( + ctx: { db: any; user: { id: string } }, + formId: string, +) { + const form = await ctx.db.query.forms.findFirst({ + where: (table: any) => + and(eq(table.id, formId), eq(table.userId, ctx.user.id)), + }); + + if (!form) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Form not found', + }); + } + + return form; +} + +export const formsRouter = createApiV1Router({ + list: apiKeyProcedure + .meta({ + openapi: { + method: 'GET', + path: '/forms', + tags: ['Forms'], + summary: 'List all forms', + description: + 'Returns a paginated list of forms owned by the authenticated user.', + }, + }) + .input(paginationInputSchema) + .output( + z.object({ + forms: z.array( + z.object({ + id: z.string(), + title: z.string(), + description: z.string().nullable(), + returnUrl: z.string().nullable(), + keys: z.array(z.string()), + submissionCount: z.number(), + createdAt: z.string(), + updatedAt: z.string().nullable(), + }), + ), + pagination: paginationOutputSchema, + }), + ) + .query(async ({ ctx, input }) => { + const userId = ctx.user.id; + const offset = (input.page - 1) * input.perPage; + + const [formsList, totalResult] = await Promise.all([ + ctx.db.query.forms.findMany({ + where: (table: any) => eq(table.userId, userId), + offset, + limit: input.perPage, + orderBy: (table: any, { desc }: any) => desc(table.createdAt), + with: { + formData: true, + }, + }), + ctx.db + .select({ count: count() }) + .from(forms) + .where(eq(forms.userId, userId)), + ]); + + const total = totalResult[0]?.count ?? 0; + + return { + forms: formsList.map((form: any) => ({ + id: form.id, + title: form.title, + description: form.description, + returnUrl: form.returnUrl, + keys: parseJsonArray(form.keys), + submissionCount: form.formData?.length ?? 0, + createdAt: form.createdAt.toISOString(), + updatedAt: form.updatedAt?.toISOString() ?? null, + })), + pagination: { + page: input.page, + perPage: input.perPage, + total, + totalPages: Math.ceil(total / input.perPage), + }, + }; + }), + + create: apiKeyProcedure + .meta({ + openapi: { + method: 'POST', + path: '/forms', + tags: ['Forms'], + summary: 'Create a new form', + }, + }) + .input(createFormInputSchema) + .output(z.object({ id: z.string() })) + .mutation(async ({ ctx, input }) => { + const id = generateId(15); + const userEmail = ctx.user.email; + + await ctx.db.insert(forms).values({ + id, + userId: ctx.user.id, + title: input.title, + description: input.description ?? null, + updatedAt: new Date(), + returnUrl: input.returnUrl ?? null, + keys: serializeJson([]), + enableEmailNotifications: true, + enableSubmissions: true, + defaultSubmissionEmail: userEmail, + }); + + return { id }; + }), + + get: apiKeyProcedure + .meta({ + openapi: { + method: 'GET', + path: '/forms/{formId}', + tags: ['Forms'], + summary: 'Get a single form', + }, + }) + .input(z.object({ formId: z.string() })) + .output( + z.object({ + id: z.string(), + title: z.string(), + description: z.string().nullable(), + returnUrl: z.string().nullable(), + keys: z.array(z.string()), + submissionCount: z.number(), + createdAt: z.string(), + updatedAt: z.string().nullable(), + }), + ) + .query(async ({ ctx, input }) => { + const form = await ctx.db.query.forms.findFirst({ + where: (table: any) => + and(eq(table.id, input.formId), eq(table.userId, ctx.user.id)), + with: { formData: true }, + }); + + if (!form) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Form not found', + }); + } + + return { + id: form.id, + title: form.title, + description: form.description, + returnUrl: form.returnUrl, + keys: parseJsonArray(form.keys), + submissionCount: form.formData?.length ?? 0, + createdAt: form.createdAt.toISOString(), + updatedAt: form.updatedAt?.toISOString() ?? null, + }; + }), + + update: apiKeyProcedure + .meta({ + openapi: { + method: 'PATCH', + path: '/forms/{formId}', + tags: ['Forms'], + summary: 'Update a form', + }, + }) + .input(updateFormInputSchema) + .output(z.object({ success: z.boolean() })) + .mutation(async ({ ctx, input }) => { + const form = await assertApiFormOwnership(ctx, input.formId); + + await ctx.db + .update(forms) + .set({ + title: input.title ?? form.title, + description: input.description ?? form.description, + returnUrl: input.returnUrl ?? form.returnUrl, + updatedAt: new Date(), + }) + .where(eq(forms.id, input.formId)); + + return { success: true }; + }), + + delete: apiKeyProcedure + .meta({ + openapi: { + method: 'DELETE', + path: '/forms/{formId}', + tags: ['Forms'], + summary: 'Delete a form', + description: 'Permanently deletes a form and all its submissions.', + }, + }) + .input(z.object({ formId: z.string() })) + .output(z.object({ success: z.boolean() })) + .mutation(async ({ ctx, input }) => { + await assertApiFormOwnership(ctx, input.formId); + await ctx.db.delete(forms).where(eq(forms.id, input.formId)); + return { success: true }; + }), + + duplicate: apiKeyProcedure + .meta({ + openapi: { + method: 'POST', + path: '/forms/{formId}/duplicate', + tags: ['Forms'], + summary: 'Duplicate a form', + description: 'Creates a copy of a form with its configuration.', + }, + }) + .input(z.object({ formId: z.string() })) + .output(z.object({ id: z.string() })) + .mutation(async ({ ctx, input }) => { + const existingForm = await assertApiFormOwnership(ctx, input.formId); + const newId = generateId(15); + + await ctx.db.insert(forms).values({ + id: newId, + userId: ctx.user.id, + title: `${existingForm.title} (Copy)`, + description: existingForm.description, + updatedAt: new Date(), + returnUrl: existingForm.returnUrl, + keys: existingForm.keys, + enableEmailNotifications: existingForm.enableEmailNotifications, + enableSubmissions: existingForm.enableSubmissions, + defaultSubmissionEmail: existingForm.defaultSubmissionEmail, + }); + + return { id: newId }; + }), + + bulkCreate: apiKeyProcedure + .meta({ + openapi: { + method: 'POST', + path: '/forms/bulk', + tags: ['Forms'], + summary: 'Create multiple forms', + }, + }) + .input(bulkCreateFormInputSchema) + .output(z.object({ ids: z.array(z.string()) })) + .mutation(async ({ ctx, input }) => { + const userEmail = ctx.user.email; + const ids: string[] = []; + + for (const form of input.forms) { + const id = generateId(15); + ids.push(id); + + await ctx.db.insert(forms).values({ + id, + userId: ctx.user.id, + title: form.title, + description: form.description ?? null, + updatedAt: new Date(), + returnUrl: form.returnUrl ?? null, + keys: serializeJson([]), + enableEmailNotifications: true, + enableSubmissions: true, + defaultSubmissionEmail: userEmail, + }); + } + + return { ids }; + }), + + bulkUpdate: apiKeyProcedure + .meta({ + openapi: { + method: 'PATCH', + path: '/forms/bulk', + tags: ['Forms'], + summary: 'Update multiple forms', + }, + }) + .input(bulkUpdateFormInputSchema) + .output(z.object({ success: z.boolean() })) + .mutation(async ({ ctx, input }) => { + for (const formUpdate of input.forms) { + const form = await assertApiFormOwnership(ctx, formUpdate.id); + + await ctx.db + .update(forms) + .set({ + title: formUpdate.title ?? form.title, + description: formUpdate.description ?? form.description, + returnUrl: formUpdate.returnUrl ?? form.returnUrl, + updatedAt: new Date(), + }) + .where(eq(forms.id, formUpdate.id)); + } + + return { success: true }; + }), + + bulkDelete: apiKeyProcedure + .meta({ + openapi: { + method: 'DELETE', + path: '/forms/bulk', + tags: ['Forms'], + summary: 'Delete multiple forms', + }, + }) + .input(bulkDeleteInputSchema) + .output(z.object({ success: z.boolean() })) + .mutation(async ({ ctx, input }) => { + const userForms = await ctx.db.query.forms.findMany({ + where: (table: any) => + and(inArray(table.id, input.ids), eq(table.userId, ctx.user.id)), + }); + + const ownedIds = userForms.map((f: any) => f.id); + const notFound = input.ids.filter((id) => !ownedIds.includes(id)); + + if (notFound.length > 0) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: `Forms not found: ${notFound.join(', ')}`, + }); + } + + await ctx.db.delete(forms).where(inArray(forms.id, input.ids)); + + return { success: true }; + }), +}); diff --git a/packages/api/routers/api-v1/index.ts b/packages/api/routers/api-v1/index.ts new file mode 100644 index 0000000..91bcf60 --- /dev/null +++ b/packages/api/routers/api-v1/index.ts @@ -0,0 +1,12 @@ +import { formsRouter } from './forms'; +import { submissionsRouter } from './submissions'; +import { createApiV1Router } from './trpc'; + +export const apiV1Router = createApiV1Router({ + forms: formsRouter, + submissions: submissionsRouter, +}); + +export type ApiV1Router = typeof apiV1Router; + +export { createApiV1Context, type ApiV1Context } from './trpc'; diff --git a/packages/api/routers/api-v1/schemas.ts b/packages/api/routers/api-v1/schemas.ts new file mode 100644 index 0000000..8566086 --- /dev/null +++ b/packages/api/routers/api-v1/schemas.ts @@ -0,0 +1,79 @@ +import { z } from 'zod'; + +export const paginationInputSchema = z.object({ + page: z.number().int().min(1).default(1), + perPage: z.number().int().min(1).max(100).default(20), +}); + +export const paginationOutputSchema = z.object({ + page: z.number(), + perPage: z.number(), + total: z.number(), + totalPages: z.number(), +}); + +export const formSchema = z.object({ + id: z.string(), + title: z.string(), + description: z.string().nullable(), + returnUrl: z.string().nullable(), + keys: z.array(z.string()), + submissionCount: z.number(), + createdAt: z.string(), + updatedAt: z.string().nullable(), +}); + +export const submissionSchema = z.object({ + id: z.string(), + formId: z.string(), + data: z.record(z.unknown()), + createdAt: z.string(), +}); + +export const errorSchema = z.object({ + error: z.object({ + code: z.string(), + message: z.string(), + }), +}); + +export const createFormInputSchema = z.object({ + title: z.string().min(1).max(255), + description: z.string().max(1000).optional(), + returnUrl: z.string().url().optional(), +}); + +export const updateFormInputSchema = z.object({ + formId: z.string(), + title: z.string().min(1).max(255).optional(), + description: z.string().max(1000).optional(), + returnUrl: z.string().url().optional(), +}); + +export const bulkCreateFormInputSchema = z.object({ + forms: z.array(createFormInputSchema), +}); + +export const bulkUpdateFormInputSchema = z.object({ + forms: z.array( + z.object({ + id: z.string(), + title: z.string().min(1).max(255).optional(), + description: z.string().max(1000).optional(), + returnUrl: z.string().url().optional(), + }), + ), +}); + +export const bulkDeleteInputSchema = z.object({ + ids: z.array(z.string()), +}); + +export const dateRangeInputSchema = z.object({ + startDate: z.string().optional(), + endDate: z.string().optional(), +}); + +export type PaginationInput = z.infer; +export type FormOutput = z.infer; +export type SubmissionOutput = z.infer; diff --git a/packages/api/routers/api-v1/submissions.ts b/packages/api/routers/api-v1/submissions.ts new file mode 100644 index 0000000..8a51c4d --- /dev/null +++ b/packages/api/routers/api-v1/submissions.ts @@ -0,0 +1,241 @@ +import { TRPCError } from '@trpc/server'; +import { z } from 'zod'; + +import { drizzlePrimitives } from '@formbase/db'; +import { formDatas } from '@formbase/db/schema'; + +const { and, eq, gte, inArray, lte } = drizzlePrimitives; + +import { parseJsonObject } from '../../utils/json'; +import { + bulkDeleteInputSchema, + dateRangeInputSchema, + paginationInputSchema, + paginationOutputSchema, +} from './schemas'; +import { apiKeyProcedure, createApiV1Router } from './trpc'; + +async function assertApiFormOwnership( + ctx: { db: any; user: { id: string } }, + formId: string, +) { + const form = await ctx.db.query.forms.findFirst({ + where: (table: any) => + and(eq(table.id, formId), eq(table.userId, ctx.user.id)), + }); + + if (!form) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Form not found', + }); + } + + return form; +} + +async function assertApiSubmissionOwnership( + ctx: { db: any; user: { id: string } }, + formId: string, + submissionId: string, +) { + await assertApiFormOwnership(ctx, formId); + + const submission = await ctx.db.query.formDatas.findFirst({ + where: (table: any) => + and(eq(table.id, submissionId), eq(table.formId, formId)), + }); + + if (!submission) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Submission not found', + }); + } + + return submission; +} + +export const submissionsRouter = createApiV1Router({ + list: apiKeyProcedure + .meta({ + openapi: { + method: 'GET', + path: '/forms/{formId}/submissions', + tags: ['Submissions'], + summary: 'List submissions for a form', + description: 'Returns a paginated list of submissions with optional date filtering.', + }, + }) + .input( + paginationInputSchema.merge(dateRangeInputSchema).extend({ + formId: z.string(), + }), + ) + .output( + z.object({ + submissions: z.array( + z.object({ + id: z.string(), + formId: z.string(), + data: z.record(z.unknown()), + createdAt: z.string(), + }), + ), + pagination: paginationOutputSchema, + }), + ) + .query(async ({ ctx, input }) => { + await assertApiFormOwnership(ctx, input.formId); + + const offset = (input.page - 1) * input.perPage; + + const whereConditions: any[] = [eq(formDatas.formId, input.formId)]; + + if (input.startDate) { + whereConditions.push(gte(formDatas.createdAt, new Date(input.startDate))); + } + if (input.endDate) { + const endDate = new Date(input.endDate); + endDate.setHours(23, 59, 59, 999); + whereConditions.push(lte(formDatas.createdAt, endDate)); + } + + const whereClause = and(...whereConditions); + + const [submissionsList, totalResult] = await Promise.all([ + ctx.db.query.formDatas.findMany({ + where: () => whereClause, + offset, + limit: input.perPage, + orderBy: (table: any, { desc }: any) => desc(table.createdAt), + }), + ctx.db + .select({ count: formDatas.id }) + .from(formDatas) + .where(whereClause), + ]); + + const total = totalResult.length; + + return { + submissions: submissionsList.map((submission: any) => ({ + id: submission.id, + formId: submission.formId, + data: parseJsonObject(submission.data) ?? {}, + createdAt: submission.createdAt.toISOString(), + })), + pagination: { + page: input.page, + perPage: input.perPage, + total, + totalPages: Math.ceil(total / input.perPage), + }, + }; + }), + + get: apiKeyProcedure + .meta({ + openapi: { + method: 'GET', + path: '/forms/{formId}/submissions/{submissionId}', + tags: ['Submissions'], + summary: 'Get a single submission', + }, + }) + .input( + z.object({ + formId: z.string(), + submissionId: z.string(), + }), + ) + .output( + z.object({ + id: z.string(), + formId: z.string(), + data: z.record(z.unknown()), + createdAt: z.string(), + }), + ) + .query(async ({ ctx, input }) => { + const submission = await assertApiSubmissionOwnership( + ctx, + input.formId, + input.submissionId, + ); + + return { + id: submission.id, + formId: submission.formId, + data: parseJsonObject(submission.data) ?? {}, + createdAt: submission.createdAt.toISOString(), + }; + }), + + delete: apiKeyProcedure + .meta({ + openapi: { + method: 'DELETE', + path: '/forms/{formId}/submissions/{submissionId}', + tags: ['Submissions'], + summary: 'Delete a submission', + }, + }) + .input( + z.object({ + formId: z.string(), + submissionId: z.string(), + }), + ) + .output(z.object({ success: z.boolean() })) + .mutation(async ({ ctx, input }) => { + await assertApiSubmissionOwnership(ctx, input.formId, input.submissionId); + + await ctx.db + .delete(formDatas) + .where(eq(formDatas.id, input.submissionId)); + + return { success: true }; + }), + + bulkDelete: apiKeyProcedure + .meta({ + openapi: { + method: 'DELETE', + path: '/forms/{formId}/submissions/bulk', + tags: ['Submissions'], + summary: 'Delete multiple submissions', + }, + }) + .input( + bulkDeleteInputSchema.extend({ + formId: z.string(), + }), + ) + .output(z.object({ success: z.boolean() })) + .mutation(async ({ ctx, input }) => { + await assertApiFormOwnership(ctx, input.formId); + + const submissions = await ctx.db.query.formDatas.findMany({ + where: (table: any) => + and( + inArray(table.id, input.ids), + eq(table.formId, input.formId), + ), + }); + + const foundIds = submissions.map((s: any) => s.id); + const notFound = input.ids.filter((id) => !foundIds.includes(id)); + + if (notFound.length > 0) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: `Submissions not found: ${notFound.join(', ')}`, + }); + } + + await ctx.db.delete(formDatas).where(inArray(formDatas.id, input.ids)); + + return { success: true }; + }), +}); diff --git a/packages/api/routers/api-v1/trpc.ts b/packages/api/routers/api-v1/trpc.ts new file mode 100644 index 0000000..51ff11b --- /dev/null +++ b/packages/api/routers/api-v1/trpc.ts @@ -0,0 +1,87 @@ +import { initTRPC, TRPCError } from '@trpc/server'; +import type { OpenApiMeta } from 'trpc-to-openapi'; +import { ZodError } from 'zod'; + +import type { User } from '@formbase/db/schema'; +import { db } from '@formbase/db'; + +import { validateApiKey } from '../../middleware/api-auth'; +import { checkRateLimit } from '../../middleware/rate-limit'; + +export interface ApiV1Context { + db: typeof db; + headers: Headers; + apiKey?: { + id: string; + userId: string; + user: User; + }; + user?: User; + rateLimitRemaining?: number; + rateLimitReset?: number; + retryAfterSeconds?: number; +} + +export const createApiV1Context = async (opts: { + headers: Headers; +}): Promise => { + return { + db, + headers: opts.headers, + }; +}; + +const t = initTRPC + .context() + .meta() + .create({ + errorFormatter({ shape, error }) { + return { + ...shape, + data: { + ...shape.data, + zodError: + error.cause instanceof ZodError ? error.cause.flatten() : null, + }, + }; + }, + }); + +export const createApiV1Router = t.router; + +export const publicApiProcedure = t.procedure; + +export const apiKeyProcedure = t.procedure.use(async ({ ctx, next }) => { + const authorization = ctx.headers.get('authorization'); + const apiKey = await validateApiKey(authorization, ctx.db); + + if (!apiKey) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: 'Invalid or missing API key', + }); + } + + const rateLimit = checkRateLimit(apiKey.id); + + if (!rateLimit.allowed) { + throw new TRPCError({ + code: 'TOO_MANY_REQUESTS', + message: `Rate limit exceeded. Retry after ${rateLimit.retryAfterSeconds} seconds.`, + }); + } + + return next({ + ctx: { + ...ctx, + apiKey: { + id: apiKey.id, + userId: apiKey.userId, + user: apiKey.user, + }, + user: apiKey.user, + rateLimitRemaining: rateLimit.remaining, + rateLimitReset: rateLimit.resetAt.getTime(), + }, + }); +}); diff --git a/packages/db/drizzle/0001_right_menace.sql b/packages/db/drizzle/0001_right_menace.sql new file mode 100644 index 0000000..dc00493 --- /dev/null +++ b/packages/db/drizzle/0001_right_menace.sql @@ -0,0 +1,33 @@ +CREATE TABLE `api_audit_logs` ( + `id` text PRIMARY KEY NOT NULL, + `api_key_id` text, + `user_id` text NOT NULL, + `method` text NOT NULL, + `path` text NOT NULL, + `status_code` integer NOT NULL, + `ip_address` text, + `user_agent` text, + `request_body` text, + `response_time_ms` integer, + `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL, + FOREIGN KEY (`api_key_id`) REFERENCES `api_keys`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +CREATE TABLE `api_keys` ( + `id` text PRIMARY KEY NOT NULL, + `user_id` text NOT NULL, + `name` text NOT NULL, + `key_hash` text NOT NULL, + `key_prefix` text NOT NULL, + `expires_at` integer, + `last_used_at` integer, + `created_at` integer DEFAULT (cast(unixepoch('subsecond') * 1000 as integer)) NOT NULL, + FOREIGN KEY (`user_id`) REFERENCES `user`(`id`) ON UPDATE no action ON DELETE cascade +); +--> statement-breakpoint +CREATE INDEX `audit_api_key_idx` ON `api_audit_logs` (`api_key_id`);--> statement-breakpoint +CREATE INDEX `audit_user_idx` ON `api_audit_logs` (`user_id`);--> statement-breakpoint +CREATE INDEX `audit_created_at_idx` ON `api_audit_logs` (`created_at`);--> statement-breakpoint +CREATE UNIQUE INDEX `api_keys_key_hash_unique` ON `api_keys` (`key_hash`);--> statement-breakpoint +CREATE INDEX `api_key_user_idx` ON `api_keys` (`user_id`);--> statement-breakpoint +CREATE INDEX `api_key_hash_idx` ON `api_keys` (`key_hash`); \ No newline at end of file diff --git a/packages/db/drizzle/meta/0001_snapshot.json b/packages/db/drizzle/meta/0001_snapshot.json new file mode 100644 index 0000000..21dcecd --- /dev/null +++ b/packages/db/drizzle/meta/0001_snapshot.json @@ -0,0 +1,862 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "d19b0668-e19b-464b-833e-88f4b97a1222", + "prevId": "83f1e5b4-b198-480a-bf1e-da595e4f6955", + "tables": { + "account": { + "name": "account", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "account_provider_account_idx": { + "name": "account_provider_account_idx", + "columns": [ + "provider_id", + "account_id" + ], + "isUnique": true + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "api_audit_logs": { + "name": "api_audit_logs", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "api_key_id": { + "name": "api_key_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "method": { + "name": "method", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "request_body": { + "name": "request_body", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "response_time_ms": { + "name": "response_time_ms", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "audit_api_key_idx": { + "name": "audit_api_key_idx", + "columns": [ + "api_key_id" + ], + "isUnique": false + }, + "audit_user_idx": { + "name": "audit_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "audit_created_at_idx": { + "name": "audit_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "api_audit_logs_api_key_id_api_keys_id_fk": { + "name": "api_audit_logs_api_key_id_api_keys_id_fk", + "tableFrom": "api_audit_logs", + "tableTo": "api_keys", + "columnsFrom": [ + "api_key_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "api_keys": { + "name": "api_keys", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "key_prefix": { + "name": "key_prefix", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_used_at": { + "name": "last_used_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "api_keys_key_hash_unique": { + "name": "api_keys_key_hash_unique", + "columns": [ + "key_hash" + ], + "isUnique": true + }, + "api_key_user_idx": { + "name": "api_key_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "api_key_hash_idx": { + "name": "api_key_hash_idx", + "columns": [ + "key_hash" + ], + "isUnique": false + } + }, + "foreignKeys": { + "api_keys_user_id_user_id_fk": { + "name": "api_keys_user_id_user_id_fk", + "tableFrom": "api_keys", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "form_datas": { + "name": "form_datas", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "form_id": { + "name": "form_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "data": { + "name": "data", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "form_idx": { + "name": "form_idx", + "columns": [ + "form_id" + ], + "isUnique": false + }, + "form_data_created_at_idx": { + "name": "form_data_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "form_datas_form_id_forms_id_fk": { + "name": "form_datas_form_id_forms_id_fk", + "tableFrom": "form_datas", + "tableTo": "forms", + "columnsFrom": [ + "form_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "forms": { + "name": "forms", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "return_url": { + "name": "return_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "send_email_for_new_submissions": { + "name": "send_email_for_new_submissions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "keys": { + "name": "keys", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enable_submissions": { + "name": "enable_submissions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "enable_retention": { + "name": "enable_retention", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "default_submission_email": { + "name": "default_submission_email", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "form_user_idx": { + "name": "form_user_idx", + "columns": [ + "user_id" + ], + "isUnique": false + }, + "form_created_at_idx": { + "name": "form_created_at_idx", + "columns": [ + "created_at" + ], + "isUnique": false + } + }, + "foreignKeys": { + "forms_user_id_user_id_fk": { + "name": "forms_user_id_user_id_fk", + "tableFrom": "forms", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "onboarding_forms": { + "name": "onboarding_forms", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "form_id": { + "name": "form_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": {}, + "foreignKeys": { + "onboarding_forms_user_id_user_id_fk": { + "name": "onboarding_forms_user_id_user_id_fk", + "tableFrom": "onboarding_forms", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "onboarding_forms_form_id_forms_id_fk": { + "name": "onboarding_forms_form_id_forms_id_fk", + "tableFrom": "onboarding_forms", + "tableTo": "forms", + "columnsFrom": [ + "form_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "session": { + "name": "session", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + "token" + ], + "isUnique": true + }, + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + "user_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "user": { + "name": "user", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "email_verified": { + "name": "email_verified", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + "email" + ], + "isUnique": true + }, + "user_email_idx": { + "name": "user_email_idx", + "columns": [ + "email" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + }, + "verification": { + "name": "verification", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(cast(unixepoch('subsecond') * 1000 as integer))" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + "identifier" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {} + } + }, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + } +} \ No newline at end of file diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index 868b8bd..9ad44fd 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1767470022316, "tag": "0000_normal_moon_knight", "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1767830295183, + "tag": "0001_right_menace", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/index.ts b/packages/db/index.ts index 3f6a5b7..414dc5a 100644 --- a/packages/db/index.ts +++ b/packages/db/index.ts @@ -1,5 +1,5 @@ import { createClient } from '@libsql/client'; -import { and, count, eq, sql } from 'drizzle-orm'; +import { and, count, eq, gt, gte, inArray, isNull, lt, lte, or, sql } from 'drizzle-orm'; import { drizzle } from 'drizzle-orm/libsql'; import { env } from '@formbase/env'; @@ -27,6 +27,13 @@ export const db = drizzle(queryClient, { export const drizzlePrimitives = { eq, and, + or, count, sql, + gt, + gte, + lt, + lte, + inArray, + isNull, }; diff --git a/packages/db/schema/api-audit-logs.ts b/packages/db/schema/api-audit-logs.ts new file mode 100644 index 0000000..79d144c --- /dev/null +++ b/packages/db/schema/api-audit-logs.ts @@ -0,0 +1,37 @@ +import { sql } from 'drizzle-orm'; +import { createInsertSchema, createSelectSchema } from 'drizzle-zod'; +import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'; + +import { apiKeys } from './api-keys'; + +export const apiAuditLogs = sqliteTable( + 'api_audit_logs', + { + id: text('id').primaryKey(), + apiKeyId: text('api_key_id').references(() => apiKeys.id, { + onDelete: 'set null', + }), + userId: text('user_id').notNull(), + method: text('method').notNull(), + path: text('path').notNull(), + statusCode: integer('status_code').notNull(), + ipAddress: text('ip_address'), + userAgent: text('user_agent'), + requestBody: text('request_body'), + responseTimeMs: integer('response_time_ms'), + createdAt: integer('created_at', { mode: 'timestamp_ms' }) + .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) + .notNull(), + }, + (t) => ({ + apiKeyIdx: index('audit_api_key_idx').on(t.apiKeyId), + userIdx: index('audit_user_idx').on(t.userId), + createdAtIdx: index('audit_created_at_idx').on(t.createdAt), + }), +); + +export const ZSelectApiAuditLogSchema = createSelectSchema(apiAuditLogs); +export const ZInsertApiAuditLogSchema = createInsertSchema(apiAuditLogs); + +export type ApiAuditLog = typeof apiAuditLogs.$inferSelect; +export type NewApiAuditLog = typeof apiAuditLogs.$inferInsert; diff --git a/packages/db/schema/api-keys.ts b/packages/db/schema/api-keys.ts new file mode 100644 index 0000000..b84aff2 --- /dev/null +++ b/packages/db/schema/api-keys.ts @@ -0,0 +1,33 @@ +import { sql } from 'drizzle-orm'; +import { createInsertSchema, createSelectSchema } from 'drizzle-zod'; +import { index, integer, sqliteTable, text } from 'drizzle-orm/sqlite-core'; + +import { users } from './users'; + +export const apiKeys = sqliteTable( + 'api_keys', + { + id: text('id').primaryKey(), + userId: text('user_id') + .references(() => users.id, { onDelete: 'cascade' }) + .notNull(), + name: text('name').notNull(), + keyHash: text('key_hash').notNull().unique(), + keyPrefix: text('key_prefix').notNull(), + expiresAt: integer('expires_at', { mode: 'timestamp_ms' }), + lastUsedAt: integer('last_used_at', { mode: 'timestamp_ms' }), + createdAt: integer('created_at', { mode: 'timestamp_ms' }) + .default(sql`(cast(unixepoch('subsecond') * 1000 as integer))`) + .notNull(), + }, + (t) => ({ + userIdx: index('api_key_user_idx').on(t.userId), + keyHashIdx: index('api_key_hash_idx').on(t.keyHash), + }), +); + +export const ZSelectApiKeySchema = createSelectSchema(apiKeys); +export const ZInsertApiKeySchema = createInsertSchema(apiKeys); + +export type ApiKey = typeof apiKeys.$inferSelect; +export type NewApiKey = typeof apiKeys.$inferInsert; diff --git a/packages/db/schema/index.ts b/packages/db/schema/index.ts index a569a26..1cf5169 100644 --- a/packages/db/schema/index.ts +++ b/packages/db/schema/index.ts @@ -1,4 +1,6 @@ export * from './accounts'; +export * from './api-audit-logs'; +export * from './api-keys'; export * from './form-data'; export * from './forms'; export * from './onboarding-forms'; diff --git a/packages/db/schema/relations.ts b/packages/db/schema/relations.ts index 07e6df9..afe81b6 100644 --- a/packages/db/schema/relations.ts +++ b/packages/db/schema/relations.ts @@ -1,6 +1,8 @@ import { relations } from 'drizzle-orm'; import { accounts } from './accounts'; +import { apiAuditLogs } from './api-audit-logs'; +import { apiKeys } from './api-keys'; import { formDatas } from './form-data'; import { forms } from './forms'; import { sessions } from './sessions'; @@ -10,6 +12,22 @@ export const userRelations = relations(users, ({ many }) => ({ forms: many(forms), sessions: many(sessions), accounts: many(accounts), + apiKeys: many(apiKeys), +})); + +export const apiKeyRelations = relations(apiKeys, ({ one, many }) => ({ + user: one(users, { + fields: [apiKeys.userId], + references: [users.id], + }), + auditLogs: many(apiAuditLogs), +})); + +export const apiAuditLogRelations = relations(apiAuditLogs, ({ one }) => ({ + apiKey: one(apiKeys, { + fields: [apiAuditLogs.apiKeyId], + references: [apiKeys.id], + }), })); export const formRelations = relations(forms, ({ one, many }) => ({ From 16f36c04a404cbf916c170fa2e498d07100c8439 Mon Sep 17 00:00:00 2001 From: ephraimduncan Date: Thu, 8 Jan 2026 03:26:13 +0000 Subject: [PATCH 3/4] chore: cleanup --- .claude/ralph-loop.local.md | 9 - API-SPEC.md | 434 ------------------------------------ 2 files changed, 443 deletions(-) delete mode 100644 .claude/ralph-loop.local.md delete mode 100644 API-SPEC.md diff --git a/.claude/ralph-loop.local.md b/.claude/ralph-loop.local.md deleted file mode 100644 index 7002ff1..0000000 --- a/.claude/ralph-loop.local.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -active: true -iteration: 15 -max_iterations: 20 -completion_promise: "DONE" -started_at: "2026-01-07T23:48:59Z" ---- - -Read the @API-SPEC.md and implement it to build an api for the application diff --git a/API-SPEC.md b/API-SPEC.md deleted file mode 100644 index 011d938..0000000 --- a/API-SPEC.md +++ /dev/null @@ -1,434 +0,0 @@ -# Public REST API Implementation Plan - -## Overview - -Implement a public tRPC API exposed as REST via OpenAPI using `trpc-to-openapi`. The API enables programmatic management of forms and submissions with Bearer token authentication. - -## Key Decisions (from interview) - -| Aspect | Decision | -| --------------------- | ---------------------------------------------------------------------------- | -| API Key Scope | User-level (one key = all user's forms) | -| Multiple Keys | Yes, with optional expiration dates and labels | -| Form Creation | Allowed via API | -| Submissions Filtering | Date range only (inclusive both ends) | -| Delete Behavior | Hard delete | -| Response Data Format | Parsed JSON objects (not raw strings) | -| Rate Limiting | 100 req/min, 429 + Retry-After header | -| Bulk Operations | Full support (create, update, delete) | -| Duplicate Scope | Config only (no submissions) | -| Audit Logs | Full logging, 90-day retention, preserved on form delete | -| Pagination | Offset-based (?page=N&perPage=N) | -| Bulk HTTP Methods | Method matches action (DELETE for delete, POST for create, PATCH for update) | -| OpenAPI Spec | Public at `/api/v1/openapi.json` | -| Webhooks | Not in initial version | -| Form Keys | Read-only exposure | -| Form Settings | Subset only (title, description, returnUrl) | -| Error Format | Standard HTTP: `{ error: { code, message } }` | -| Versioning | URL path (`/api/v1/...`) | -| Settings UI | Add to existing settings page | -| Default Email | Auto-set to API key owner's email | -| Bulk Limit | No limit | -| Caching | No caching headers | -| List Response | Include submission count per form | - ---- - -## Database Schema Changes - -### New Table: `api_keys` - -```typescript -// packages/db/schema/api-keys.ts -export const apiKeys = sqliteTable( - 'api_keys', - { - id: text('id').primaryKey(), - userId: text('user_id') - .notNull() - .references(() => users.id, { onDelete: 'cascade' }), - name: text('name').notNull(), // User-provided label - keyHash: text('key_hash').notNull().unique(), // SHA-256 hash of the key - keyPrefix: text('key_prefix').notNull(), // First 8 chars for display (api_xxx...) - expiresAt: integer('expires_at', { mode: 'timestamp_ms' }), // Optional expiration - lastUsedAt: integer('last_used_at', { mode: 'timestamp_ms' }), - createdAt: integer('created_at', { mode: 'timestamp_ms' }) - .notNull() - .default(sql`(strftime('%s', 'now') * 1000)`), - }, - (table) => ({ - userIdx: index('api_key_user_idx').on(table.userId), - keyHashIdx: index('api_key_hash_idx').on(table.keyHash), - }), -); -``` - -### New Table: `api_audit_logs` - -```typescript -// packages/db/schema/api-audit-logs.ts -export const apiAuditLogs = sqliteTable( - 'api_audit_logs', - { - id: text('id').primaryKey(), - apiKeyId: text('api_key_id') - .notNull() - .references(() => apiKeys.id, { onDelete: 'set null' }), - userId: text('user_id').notNull(), // Denormalized for preservation - method: text('method').notNull(), // GET, POST, DELETE, etc. - path: text('path').notNull(), // /api/v1/forms/xxx - statusCode: integer('status_code').notNull(), // 200, 404, 429, etc. - ipAddress: text('ip_address'), - userAgent: text('user_agent'), - requestBody: text('request_body'), // JSON string (sanitized) - responseTimeMs: integer('response_time_ms'), - createdAt: integer('created_at', { mode: 'timestamp_ms' }) - .notNull() - .default(sql`(strftime('%s', 'now') * 1000)`), - }, - (table) => ({ - apiKeyIdx: index('audit_api_key_idx').on(table.apiKeyId), - userIdx: index('audit_user_idx').on(table.userId), - createdAtIdx: index('audit_created_at_idx').on(table.createdAt), - }), -); -``` - ---- - -## API Endpoints - -### Authentication - -All endpoints require `Authorization: Bearer api_xxxxxxxxxxxxx` header. - -### Forms - -| Method | Path | Description | -| ------ | ---------------------------------- | ------------------------------------------------------- | -| GET | `/api/v1/forms` | List user's forms with pagination and submission counts | -| POST | `/api/v1/forms` | Create a new form | -| GET | `/api/v1/forms/{formId}` | Get a single form | -| PATCH | `/api/v1/forms/{formId}` | Update a form (title, description, returnUrl) | -| DELETE | `/api/v1/forms/{formId}` | Delete a form and all submissions | -| POST | `/api/v1/forms/{formId}/duplicate` | Duplicate a form (config only) | - -### Bulk Forms - -| Method | Path | Description | -| ------ | -------------------- | --------------------- | -| POST | `/api/v1/forms/bulk` | Create multiple forms | -| PATCH | `/api/v1/forms/bulk` | Update multiple forms | -| DELETE | `/api/v1/forms/bulk` | Delete multiple forms | - -### Submissions - -| Method | Path | Description | -| ------ | --------------------------------------------------- | ------------------------------------------------ | -| GET | `/api/v1/forms/{formId}/submissions` | List submissions with pagination and date filter | -| GET | `/api/v1/forms/{formId}/submissions/{submissionId}` | Get a single submission | -| DELETE | `/api/v1/forms/{formId}/submissions/{submissionId}` | Delete a submission | - -### Bulk Submissions - -| Method | Path | Description | -| ------ | ----------------------------------------- | --------------------------- | -| DELETE | `/api/v1/forms/{formId}/submissions/bulk` | Delete multiple submissions | - -### OpenAPI - -| Method | Path | Description | -| ------ | ---------------------- | ---------------------------------- | -| GET | `/api/v1/openapi.json` | OpenAPI 3.0 specification (public) | - ---- - -## Request/Response Schemas - -### List Forms Response - -```json -{ - "forms": [ - { - "id": "abc123", - "title": "Contact Form", - "description": "Website contact form", - "returnUrl": "https://example.com/thanks", - "keys": ["name", "email", "message"], - "submissionCount": 42, - "createdAt": "2024-01-15T10:30:00Z", - "updatedAt": "2024-01-20T14:22:00Z" - } - ], - "pagination": { - "page": 1, - "perPage": 20, - "total": 45, - "totalPages": 3 - } -} -``` - -### Create Form Request - -```json -{ - "title": "New Form", - "description": "Optional description", - "returnUrl": "https://example.com/thanks" -} -``` - -### List Submissions Request - -``` -GET /api/v1/forms/{formId}/submissions?page=1&perPage=50&startDate=2024-01-01&endDate=2024-01-31 -``` - -### List Submissions Response - -```json -{ - "submissions": [ - { - "id": "sub123", - "formId": "form456", - "data": { - "name": "John Doe", - "email": "john@example.com", - "message": "Hello!" - }, - "createdAt": "2024-01-15T10:30:00Z" - } - ], - "pagination": { - "page": 1, - "perPage": 50, - "total": 120, - "totalPages": 3 - } -} -``` - -### Bulk Delete Request - -```json -{ - "ids": ["id1", "id2", "id3"] -} -``` - -### Error Response - -```json -{ - "error": { - "code": "NOT_FOUND", - "message": "Form not found" - } -} -``` - ---- - -## Implementation Structure - -### New Files - -``` -packages/ -├── api/ -│ ├── routers/ -│ │ ├── api-v1/ -│ │ │ ├── index.ts # v1 router combining all sub-routers -│ │ │ ├── forms.ts # Form CRUD + bulk operations -│ │ │ ├── submissions.ts # Submission operations -│ │ │ └── meta.ts # OpenAPI metadata definitions -│ │ └── api-keys.ts # API key management (for settings UI) -│ ├── middleware/ -│ │ ├── api-auth.ts # Bearer token validation -│ │ └── rate-limit.ts # Rate limiting logic -│ └── lib/ -│ ├── api-key.ts # Key generation, hashing utilities -│ └── audit-log.ts # Audit logging helper -├── db/ -│ └── schema/ -│ ├── api-keys.ts # API keys table -│ └── api-audit-logs.ts # Audit logs table - -apps/web/ -├── src/ -│ ├── app/ -│ │ └── api/ -│ │ └── v1/ -│ │ ├── [...trpc]/ -│ │ │ └── route.ts # tRPC-OpenAPI handler -│ │ └── openapi.json/ -│ │ └── route.ts # OpenAPI spec endpoint -│ └── components/ -│ └── settings/ -│ └── api-keys-section.tsx # UI for managing API keys -``` - -### Key Implementation Details - -#### 1. API Key Authentication (`packages/api/middleware/api-auth.ts`) - -```typescript -export async function validateApiKey( - authorization: string | null, - db: Database, -) { - if (!authorization?.startsWith('Bearer ')) { - return null; - } - - const token = authorization.slice(7); - const keyHash = hashApiKey(token); - - const apiKey = await db.query.apiKeys.findFirst({ - where: (table) => - and( - eq(table.keyHash, keyHash), - or(isNull(table.expiresAt), gt(table.expiresAt, new Date())), - ), - with: { user: true }, - }); - - if (apiKey) { - // Update lastUsedAt asynchronously - db.update(apiKeys) - .set({ lastUsedAt: new Date() }) - .where(eq(apiKeys.id, apiKey.id)); - } - - return apiKey; -} -``` - -#### 2. Rate Limiting (`packages/api/middleware/rate-limit.ts`) - -- Use in-memory sliding window counter (or Upstash Redis if available) -- Key: IP address or API key ID -- Limit: 100 requests per minute -- Returns `Retry-After` header with seconds until reset - -#### 3. OpenAPI Metadata Pattern - -```typescript -// packages/api/routers/api-v1/meta.ts -export const listFormsMeta: OpenApiMeta = { - openapi: { - method: 'GET', - path: '/api/v1/forms', - tags: ['Forms'], - summary: 'List all forms', - description: - 'Returns a paginated list of forms owned by the authenticated user.', - }, -}; -``` - -#### 4. tRPC-OpenAPI Handler - -```typescript -// apps/web/src/app/api/v1/[...trpc]/route.ts - -import { createOpenApiNextHandler } from 'trpc-to-openapi'; - -const handler = createOpenApiNextHandler({ - router: apiV1Router, - createContext: createApiContext, - responseMeta: () => ({ - headers: { - 'X-RateLimit-Remaining': '...', - 'X-RateLimit-Reset': '...', - }, - }), - onError: ({ error, path }) => { - logAuditError(error, path); - }, -}); -``` - -#### 5. Audit Log Cleanup Job - -- Run daily via cron or scheduled function -- Delete logs older than 90 days: - -```sql -DELETE FROM api_audit_logs WHERE created_at < (now - 90 days) -``` - ---- - -## Settings UI - -### API Keys Section (add to existing settings page) - -Features: - -- List existing API keys (name, prefix, created date, last used, expiration) -- Create new key (name input, optional expiration date picker) -- Show full key ONCE on creation (modal with copy button) -- Delete key with confirmation -- View usage stats (total requests, last 24h requests from audit logs) - ---- - -## Verification Plan - -1. **Unit Tests** - - API key generation and hashing - - Rate limit logic - - Date range filtering - -2. **Integration Tests** - - Create/list/update/delete forms via REST - - Bulk operations - - Pagination - - Rate limit enforcement - - Invalid/expired key rejection - -3. **Manual Testing** - - Generate OpenAPI spec and validate with Swagger UI - - Test with curl commands - - Test with Postman/Insomnia - - Verify rate limiting with rapid requests - - Test audit log entries - -4. **UI Testing** - - Create API key and verify display - - Copy key functionality - - Delete key functionality - - View usage stats - ---- - -## Dependencies to Add - -```json -{ - "dependencies": { - "trpc-to-openapi": "^2.0.0", - "@upstash/ratelimit": "^2.0.0" // Optional, can use in-memory - } -} -``` - ---- - -## Migration Steps - -1. Create and run database migrations for new tables -2. Implement API key utilities (generate, hash, validate) -3. Implement rate limiting middleware -4. Create v1 tRPC router with OpenAPI metadata -5. Set up REST handler at `/api/v1/[...trpc]` -6. Add OpenAPI spec endpoint -7. Implement audit logging -8. Build settings UI for API key management -9. Add audit log cleanup job -10. Write tests -11. Update documentation From ebc380acbf8d874b0b999a538dde2cd391a232b7 Mon Sep 17 00:00:00 2001 From: ephraimduncan Date: Fri, 9 Jan 2026 06:27:54 +0000 Subject: [PATCH 4/4] fix: remove any types from API v1 routers --- .claude/settings.local.json | 6 +- packages/api/routers/api-keys.ts.bak | 96 ---------------------- packages/api/routers/api-v1/forms.ts | 61 +++----------- packages/api/routers/api-v1/ownership.ts | 48 +++++++++++ packages/api/routers/api-v1/schemas.ts | 11 --- packages/api/routers/api-v1/submissions.ts | 80 ++++-------------- 6 files changed, 79 insertions(+), 223 deletions(-) delete mode 100644 packages/api/routers/api-keys.ts.bak create mode 100644 packages/api/routers/api-v1/ownership.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 369379d..b71e42d 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -12,7 +12,11 @@ "WebFetch(domain:avvvatars.com)", "Bash(bun add:*)", "mcp__plugin_context7_context7__resolve-library-id", - "mcp__plugin_context7_context7__query-docs" + "mcp__plugin_context7_context7__query-docs", + "Bash(git -C /Users/duncan/dev/formbase rm:*)", + "Bash(git -C /Users/duncan/dev/formbase diff --stat)", + "Bash(git -C /Users/duncan/dev/formbase status)", + "Bash(git -C /Users/duncan/dev/formbase add packages/api/routers/api-v1/forms.ts packages/api/routers/api-v1/submissions.ts)" ] } } diff --git a/packages/api/routers/api-keys.ts.bak b/packages/api/routers/api-keys.ts.bak deleted file mode 100644 index 82223f9..0000000 --- a/packages/api/routers/api-keys.ts.bak +++ /dev/null @@ -1,96 +0,0 @@ -import { TRPCError } from '@trpc/server'; -import { and, desc, eq, gte } from 'drizzle-orm'; -import { z } from 'zod'; - -import { drizzlePrimitives } from '@formbase/db'; -import { apiAuditLogs, apiKeys } from '@formbase/db/schema'; -import { generateId } from '@formbase/utils/generate-id'; - -import { generateApiKey, hashApiKey } from '../lib/api-key'; -import { createRouter, protectedProcedure } from '../trpc'; - -export const apiKeysRouter = createRouter({ - list: protectedProcedure.query(async ({ ctx }) => { - const keys = await ctx.db.query.apiKeys.findMany({ - where: (table) => eq(table.userId, ctx.user.id), - orderBy: (table) => desc(table.createdAt), - }); - return keys; - }), - - create: protectedProcedure - .input( - z.object({ - name: z.string().min(1).max(100), - expiresAt: z.date().optional(), - }), - ) - .mutation(async ({ ctx, input }) => { - const { key, prefix, hash } = generateApiKey(); - - await ctx.db.insert(apiKeys).values({ - id: generateId(15), - userId: ctx.user.id, - name: input.name, - keyHash: hash, - keyPrefix: prefix, - expiresAt: input.expiresAt ?? null, - }); - - return { key }; - }), - - delete: protectedProcedure - .input(z.object({ id: z.string() })) - .mutation(async ({ ctx, input }) => { - const apiKey = await ctx.db.query.apiKeys.findFirst({ - where: (table) => - and(eq(table.id, input.id), eq(table.userId, ctx.user.id)), - }); - - if (!apiKey) { - throw new TRPCError({ code: 'NOT_FOUND', message: 'API key not found' }); - } - - await ctx.db - .delete(apiKeys) - .where(drizzlePrimitives.eq(apiKeys.id, input.id)); - }), - - getUsageStats: protectedProcedure - .input(z.object({ id: z.string() })) - .query(async ({ ctx, input }) => { - const apiKey = await ctx.db.query.apiKeys.findFirst({ - where: (table) => - and(eq(table.id, input.id), eq(table.userId, ctx.user.id)), - }); - - if (!apiKey) { - throw new TRPCError({ code: 'NOT_FOUND', message: 'API key not found' }); - } - - const now = new Date(); - const last24h = new Date(now.getTime() - 24 * 60 * 60 * 1000); - - const [totalResult, last24hResult] = await Promise.all([ - ctx.db - .select({ count: drizzlePrimitives.count() }) - .from(apiAuditLogs) - .where(eq(apiAuditLogs.apiKeyId, input.id)), - ctx.db - .select({ count: drizzlePrimitives.count() }) - .from(apiAuditLogs) - .where( - and( - eq(apiAuditLogs.apiKeyId, input.id), - gte(apiAuditLogs.createdAt, last24h), - ), - ), - ]); - - return { - total: totalResult[0]?.count ?? 0, - last24h: last24hResult[0]?.count ?? 0, - }; - }), -}); diff --git a/packages/api/routers/api-v1/forms.ts b/packages/api/routers/api-v1/forms.ts index 4341b07..4c06c29 100644 --- a/packages/api/routers/api-v1/forms.ts +++ b/packages/api/routers/api-v1/forms.ts @@ -3,40 +3,23 @@ import { z } from 'zod'; import { drizzlePrimitives } from '@formbase/db'; import { forms } from '@formbase/db/schema'; - -const { and, count, eq, inArray } = drizzlePrimitives; import { generateId } from '@formbase/utils/generate-id'; import { parseJsonArray, serializeJson } from '../../utils/json'; +import { assertApiFormOwnership } from './ownership'; import { bulkCreateFormInputSchema, bulkDeleteInputSchema, bulkUpdateFormInputSchema, createFormInputSchema, + formSchema, paginationInputSchema, paginationOutputSchema, updateFormInputSchema, } from './schemas'; import { apiKeyProcedure, createApiV1Router } from './trpc'; -async function assertApiFormOwnership( - ctx: { db: any; user: { id: string } }, - formId: string, -) { - const form = await ctx.db.query.forms.findFirst({ - where: (table: any) => - and(eq(table.id, formId), eq(table.userId, ctx.user.id)), - }); - - if (!form) { - throw new TRPCError({ - code: 'NOT_FOUND', - message: 'Form not found', - }); - } - - return form; -} +const { and, count, eq, inArray } = drizzlePrimitives; export const formsRouter = createApiV1Router({ list: apiKeyProcedure @@ -53,18 +36,7 @@ export const formsRouter = createApiV1Router({ .input(paginationInputSchema) .output( z.object({ - forms: z.array( - z.object({ - id: z.string(), - title: z.string(), - description: z.string().nullable(), - returnUrl: z.string().nullable(), - keys: z.array(z.string()), - submissionCount: z.number(), - createdAt: z.string(), - updatedAt: z.string().nullable(), - }), - ), + forms: z.array(formSchema), pagination: paginationOutputSchema, }), ) @@ -74,10 +46,10 @@ export const formsRouter = createApiV1Router({ const [formsList, totalResult] = await Promise.all([ ctx.db.query.forms.findMany({ - where: (table: any) => eq(table.userId, userId), + where: (table) => eq(table.userId, userId), offset, limit: input.perPage, - orderBy: (table: any, { desc }: any) => desc(table.createdAt), + orderBy: (table, { desc }) => desc(table.createdAt), with: { formData: true, }, @@ -91,7 +63,7 @@ export const formsRouter = createApiV1Router({ const total = totalResult[0]?.count ?? 0; return { - forms: formsList.map((form: any) => ({ + forms: formsList.map((form) => ({ id: form.id, title: form.title, description: form.description, @@ -151,21 +123,10 @@ export const formsRouter = createApiV1Router({ }, }) .input(z.object({ formId: z.string() })) - .output( - z.object({ - id: z.string(), - title: z.string(), - description: z.string().nullable(), - returnUrl: z.string().nullable(), - keys: z.array(z.string()), - submissionCount: z.number(), - createdAt: z.string(), - updatedAt: z.string().nullable(), - }), - ) + .output(formSchema) .query(async ({ ctx, input }) => { const form = await ctx.db.query.forms.findFirst({ - where: (table: any) => + where: (table) => and(eq(table.id, input.formId), eq(table.userId, ctx.user.id)), with: { formData: true }, }); @@ -344,11 +305,11 @@ export const formsRouter = createApiV1Router({ .output(z.object({ success: z.boolean() })) .mutation(async ({ ctx, input }) => { const userForms = await ctx.db.query.forms.findMany({ - where: (table: any) => + where: (table) => and(inArray(table.id, input.ids), eq(table.userId, ctx.user.id)), }); - const ownedIds = userForms.map((f: any) => f.id); + const ownedIds = userForms.map((f) => f.id); const notFound = input.ids.filter((id) => !ownedIds.includes(id)); if (notFound.length > 0) { diff --git a/packages/api/routers/api-v1/ownership.ts b/packages/api/routers/api-v1/ownership.ts new file mode 100644 index 0000000..f83f29d --- /dev/null +++ b/packages/api/routers/api-v1/ownership.ts @@ -0,0 +1,48 @@ +import { TRPCError } from '@trpc/server'; + +import { drizzlePrimitives } from '@formbase/db'; + +const { and, eq } = drizzlePrimitives; + +type DbContext = { db: typeof import('@formbase/db').db; user: { id: string } }; + +export async function assertApiFormOwnership( + ctx: DbContext, + formId: string, +) { + const form = await ctx.db.query.forms.findFirst({ + where: (table) => + and(eq(table.id, formId), eq(table.userId, ctx.user.id)), + }); + + if (!form) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Form not found', + }); + } + + return form; +} + +export async function assertApiSubmissionOwnership( + ctx: DbContext, + formId: string, + submissionId: string, +) { + await assertApiFormOwnership(ctx, formId); + + const submission = await ctx.db.query.formDatas.findFirst({ + where: (table) => + and(eq(table.id, submissionId), eq(table.formId, formId)), + }); + + if (!submission) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Submission not found', + }); + } + + return submission; +} diff --git a/packages/api/routers/api-v1/schemas.ts b/packages/api/routers/api-v1/schemas.ts index 8566086..9797536 100644 --- a/packages/api/routers/api-v1/schemas.ts +++ b/packages/api/routers/api-v1/schemas.ts @@ -30,13 +30,6 @@ export const submissionSchema = z.object({ createdAt: z.string(), }); -export const errorSchema = z.object({ - error: z.object({ - code: z.string(), - message: z.string(), - }), -}); - export const createFormInputSchema = z.object({ title: z.string().min(1).max(255), description: z.string().max(1000).optional(), @@ -73,7 +66,3 @@ export const dateRangeInputSchema = z.object({ startDate: z.string().optional(), endDate: z.string().optional(), }); - -export type PaginationInput = z.infer; -export type FormOutput = z.infer; -export type SubmissionOutput = z.infer; diff --git a/packages/api/routers/api-v1/submissions.ts b/packages/api/routers/api-v1/submissions.ts index 8a51c4d..1ee15b8 100644 --- a/packages/api/routers/api-v1/submissions.ts +++ b/packages/api/routers/api-v1/submissions.ts @@ -4,57 +4,21 @@ import { z } from 'zod'; import { drizzlePrimitives } from '@formbase/db'; import { formDatas } from '@formbase/db/schema'; -const { and, eq, gte, inArray, lte } = drizzlePrimitives; - import { parseJsonObject } from '../../utils/json'; +import { + assertApiFormOwnership, + assertApiSubmissionOwnership, +} from './ownership'; import { bulkDeleteInputSchema, dateRangeInputSchema, paginationInputSchema, paginationOutputSchema, + submissionSchema, } from './schemas'; import { apiKeyProcedure, createApiV1Router } from './trpc'; -async function assertApiFormOwnership( - ctx: { db: any; user: { id: string } }, - formId: string, -) { - const form = await ctx.db.query.forms.findFirst({ - where: (table: any) => - and(eq(table.id, formId), eq(table.userId, ctx.user.id)), - }); - - if (!form) { - throw new TRPCError({ - code: 'NOT_FOUND', - message: 'Form not found', - }); - } - - return form; -} - -async function assertApiSubmissionOwnership( - ctx: { db: any; user: { id: string } }, - formId: string, - submissionId: string, -) { - await assertApiFormOwnership(ctx, formId); - - const submission = await ctx.db.query.formDatas.findFirst({ - where: (table: any) => - and(eq(table.id, submissionId), eq(table.formId, formId)), - }); - - if (!submission) { - throw new TRPCError({ - code: 'NOT_FOUND', - message: 'Submission not found', - }); - } - - return submission; -} +const { and, count, eq, gte, inArray, lte } = drizzlePrimitives; export const submissionsRouter = createApiV1Router({ list: apiKeyProcedure @@ -74,14 +38,7 @@ export const submissionsRouter = createApiV1Router({ ) .output( z.object({ - submissions: z.array( - z.object({ - id: z.string(), - formId: z.string(), - data: z.record(z.unknown()), - createdAt: z.string(), - }), - ), + submissions: z.array(submissionSchema), pagination: paginationOutputSchema, }), ) @@ -90,7 +47,7 @@ export const submissionsRouter = createApiV1Router({ const offset = (input.page - 1) * input.perPage; - const whereConditions: any[] = [eq(formDatas.formId, input.formId)]; + const whereConditions = [eq(formDatas.formId, input.formId)]; if (input.startDate) { whereConditions.push(gte(formDatas.createdAt, new Date(input.startDate))); @@ -108,18 +65,18 @@ export const submissionsRouter = createApiV1Router({ where: () => whereClause, offset, limit: input.perPage, - orderBy: (table: any, { desc }: any) => desc(table.createdAt), + orderBy: (table, { desc }) => desc(table.createdAt), }), ctx.db - .select({ count: formDatas.id }) + .select({ count: count() }) .from(formDatas) .where(whereClause), ]); - const total = totalResult.length; + const total = totalResult[0]?.count ?? 0; return { - submissions: submissionsList.map((submission: any) => ({ + submissions: submissionsList.map((submission) => ({ id: submission.id, formId: submission.formId, data: parseJsonObject(submission.data) ?? {}, @@ -149,14 +106,7 @@ export const submissionsRouter = createApiV1Router({ submissionId: z.string(), }), ) - .output( - z.object({ - id: z.string(), - formId: z.string(), - data: z.record(z.unknown()), - createdAt: z.string(), - }), - ) + .output(submissionSchema) .query(async ({ ctx, input }) => { const submission = await assertApiSubmissionOwnership( ctx, @@ -217,14 +167,14 @@ export const submissionsRouter = createApiV1Router({ await assertApiFormOwnership(ctx, input.formId); const submissions = await ctx.db.query.formDatas.findMany({ - where: (table: any) => + where: (table) => and( inArray(table.id, input.ids), eq(table.formId, input.formId), ), }); - const foundIds = submissions.map((s: any) => s.id); + const foundIds = submissions.map((s) => s.id); const notFound = input.ids.filter((id) => !foundIds.includes(id)); if (notFound.length > 0) {