diff --git a/apps/api/.eslintrc.json b/apps/api/.eslintrc.json new file mode 100644 index 0000000..91c164b --- /dev/null +++ b/apps/api/.eslintrc.json @@ -0,0 +1,19 @@ +{ + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended" + ], + "parser": "@typescript-eslint/parser", + "parserOptions": { + "ecmaVersion": 2020, + "sourceType": "module", + "project": "./tsconfig.json" + }, + "plugins": ["@typescript-eslint"], + "rules": { + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_" }] + }, + "ignorePatterns": ["dist", "node_modules", "test", "**/*.d.ts", "**/*.js"] +} + diff --git a/apps/api/.gitignore b/apps/api/.gitignore new file mode 100644 index 0000000..627f917 --- /dev/null +++ b/apps/api/.gitignore @@ -0,0 +1,21 @@ +# Build output +dist +*.tsbuildinfo + +# Dependencies +node_modules + +# Environment +.env +.env.local + +# Database +*.db +*.db-journal + +# Test coverage +coverage + +# Logs +*.log + diff --git a/apps/api/:memory:?cache=shared b/apps/api/:memory:?cache=shared new file mode 100644 index 0000000..746c263 Binary files /dev/null and b/apps/api/:memory:?cache=shared differ diff --git a/apps/api/jest.config.js b/apps/api/jest.config.js new file mode 100644 index 0000000..cfaa1eb --- /dev/null +++ b/apps/api/jest.config.js @@ -0,0 +1,19 @@ +module.exports = { + preset: 'ts-jest', + testEnvironment: 'node', + roots: ['/test'], + testMatch: ['**/*.test.ts'], + collectCoverageFrom: [ + 'src/**/*.ts', + '!src/**/*.d.ts', + ], + coverageThreshold: { + global: { + branches: 80, + functions: 80, + lines: 80, + statements: 80, + }, + }, +}; + diff --git a/apps/api/package.json b/apps/api/package.json index 06efa4d..8cdd3d4 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -6,12 +6,26 @@ "main": "dist/index.js", "types": "dist/index.d.ts", "scripts": { - "dev": "echo 'API dev server not yet implemented'", - "build": "echo 'API build not yet implemented'", - "test": "echo 'API tests not yet implemented'", + "dev": "tsx watch src/index.ts", + "build": "tsc", + "test": "jest", "lint": "eslint src" }, - "dependencies": {}, - "devDependencies": {} + "dependencies": { + "@togglekit/flags-core-ts": "workspace:*", + "fastify": "^4.25.2", + "@fastify/cors": "^8.5.0", + "better-sqlite3": "^9.2.2", + "zod": "^3.22.4" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.8", + "@types/node": "^20.11.0", + "tsx": "^4.7.0", + "typescript": "^5.3.3", + "jest": "^29.7.0", + "@types/jest": "^29.5.11", + "ts-jest": "^29.1.1" + } } diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts new file mode 100644 index 0000000..be66ecc --- /dev/null +++ b/apps/api/src/app.ts @@ -0,0 +1,79 @@ +/** + * Fastify application factory + * Creates and configures the Fastify server instance + */ + +import Fastify, { FastifyInstance } from 'fastify'; +import cors from '@fastify/cors'; +import { configRoutes } from './routes/config'; +import { flagRoutes } from './routes/flags'; +import { evaluateRoutes } from './routes/evaluate'; + +export interface AppOptions { + logger?: boolean; + cors?: { + origin: string | string[] | boolean; + }; +} + +/** + * Create and configure Fastify application + */ +export async function createApp(options: AppOptions = {}): Promise { + const app = Fastify({ + logger: options.logger ?? true, + disableRequestLogging: false, + requestIdHeader: 'x-request-id', + }); + + // Register CORS + await app.register(cors, { + origin: options.cors?.origin ?? true, + credentials: true, + }); + + // Health check endpoint + app.get('/health', async () => { + return { status: 'ok', timestamp: new Date().toISOString() }; + }); + + // Register routes + await app.register(configRoutes); + await app.register(flagRoutes); + await app.register(evaluateRoutes); + + // Global error handler + app.setErrorHandler((error, request, reply) => { + request.log.error(error); + + // Handle validation errors + if (error.validation) { + return reply.status(400).send({ + error: 'Bad Request', + message: 'Validation failed', + statusCode: 400, + details: error.validation, + }); + } + + // Default error response + const statusCode = error.statusCode ?? 500; + return reply.status(statusCode).send({ + error: error.name || 'Internal Server Error', + message: error.message || 'An unexpected error occurred', + statusCode, + }); + }); + + // 404 handler + app.setNotFoundHandler((request, reply) => { + return reply.status(404).send({ + error: 'Not Found', + message: `Route ${request.method} ${request.url} not found`, + statusCode: 404, + }); + }); + + return app; +} + diff --git a/apps/api/src/db/client.ts b/apps/api/src/db/client.ts new file mode 100644 index 0000000..e3c3bc1 --- /dev/null +++ b/apps/api/src/db/client.ts @@ -0,0 +1,347 @@ +/** + * Type-safe database client with prepared statements + * Provides high-level, type-safe API over better-sqlite3 + */ + +import Database from 'better-sqlite3'; +import crypto from 'crypto'; +import { initializeDatabase } from './migrations'; +import { + Project, + Environment, + Flag, + ProjectId, + EnvironmentId, + FlagId, + ProjectRow, + EnvironmentRow, + FlagRow, + mapProjectRow, + mapEnvironmentRow, + mapFlagRow, + CreateProjectInput, + UpdateProjectInput, + CreateEnvironmentInput, + UpdateEnvironmentInput, + CreateFlagInput, + UpdateFlagInput, +} from './types'; + +let dbInstance: DatabaseClient | null = null; + +/** + * Type-safe database client + */ +export class DatabaseClient { + private db: Database.Database; + + constructor(dbPath: string) { + this.db = new Database(dbPath); + initializeDatabase(this.db); + } + + /** + * Close the database connection + */ + close(): void { + this.db.close(); + } + + // ========================================================================== + // PROJECTS + // ========================================================================== + + /** + * Create a new project + */ + createProject(input: CreateProjectInput): Project { + const id = crypto.randomUUID(); + const stmt = this.db.prepare(` + INSERT INTO projects (id, name, created_at, updated_at) + VALUES (?, ?, datetime('now'), datetime('now')) + `); + + stmt.run(id, input.name); + return this.findProjectById(id as ProjectId)!; + } + + /** + * Find project by ID + */ + findProjectById(id: ProjectId): Project | null { + const stmt = this.db.prepare('SELECT * FROM projects WHERE id = ?'); + const row = stmt.get(id) as ProjectRow | undefined; + return row ? mapProjectRow(row) : null; + } + + /** + * List all projects + */ + listProjects(): Project[] { + const stmt = this.db.prepare('SELECT * FROM projects ORDER BY created_at DESC'); + const rows = stmt.all() as ProjectRow[]; + return rows.map(mapProjectRow); + } + + /** + * Update project + */ + updateProject(id: ProjectId, input: UpdateProjectInput): Project | null { + const updates: string[] = []; + const values: unknown[] = []; + + if (input.name !== undefined) { + updates.push('name = ?'); + values.push(input.name); + } + + if (updates.length === 0) { + return this.findProjectById(id); + } + + updates.push("updated_at = datetime('now')"); + values.push(id); + + const stmt = this.db.prepare(` + UPDATE projects + SET ${updates.join(', ')} + WHERE id = ? + `); + + stmt.run(...values); + return this.findProjectById(id); + } + + /** + * Delete project + */ + deleteProject(id: ProjectId): boolean { + const stmt = this.db.prepare('DELETE FROM projects WHERE id = ?'); + const result = stmt.run(id); + return result.changes > 0; + } + + // ========================================================================== + // ENVIRONMENTS + // ========================================================================== + + /** + * Create a new environment + */ + createEnvironment(input: CreateEnvironmentInput): Environment { + const id = crypto.randomUUID(); + const apiKey = this.generateApiKey(); + + const stmt = this.db.prepare(` + INSERT INTO environments (id, name, api_key, project_id, created_at, updated_at) + VALUES (?, ?, ?, ?, datetime('now'), datetime('now')) + `); + + stmt.run(id, input.name, apiKey, input.projectId); + return this.findEnvironmentById(id as EnvironmentId)!; + } + + /** + * Find environment by ID + */ + findEnvironmentById(id: EnvironmentId): Environment | null { + const stmt = this.db.prepare('SELECT * FROM environments WHERE id = ?'); + const row = stmt.get(id) as EnvironmentRow | undefined; + return row ? mapEnvironmentRow(row) : null; + } + + /** + * Find environment by API key + */ + findEnvironmentByApiKey(apiKey: string): Environment | null { + const stmt = this.db.prepare('SELECT * FROM environments WHERE api_key = ?'); + const row = stmt.get(apiKey) as EnvironmentRow | undefined; + return row ? mapEnvironmentRow(row) : null; + } + + /** + * List environments for a project + */ + listEnvironmentsByProject(projectId: ProjectId): Environment[] { + const stmt = this.db.prepare(` + SELECT * FROM environments + WHERE project_id = ? + ORDER BY name ASC + `); + const rows = stmt.all(projectId) as EnvironmentRow[]; + return rows.map(mapEnvironmentRow); + } + + /** + * Update environment + */ + updateEnvironment(id: EnvironmentId, input: UpdateEnvironmentInput): Environment | null { + const updates: string[] = []; + const values: unknown[] = []; + + if (input.name !== undefined) { + updates.push('name = ?'); + values.push(input.name); + } + + if (updates.length === 0) { + return this.findEnvironmentById(id); + } + + updates.push("updated_at = datetime('now')"); + values.push(id); + + const stmt = this.db.prepare(` + UPDATE environments + SET ${updates.join(', ')} + WHERE id = ? + `); + + stmt.run(...values); + return this.findEnvironmentById(id); + } + + /** + * Delete environment + */ + deleteEnvironment(id: EnvironmentId): boolean { + const stmt = this.db.prepare('DELETE FROM environments WHERE id = ?'); + const result = stmt.run(id); + return result.changes > 0; + } + + // ========================================================================== + // FLAGS + // ========================================================================== + + /** + * Create a new flag + */ + createFlag(input: CreateFlagInput): Flag { + const id = crypto.randomUUID(); + + const stmt = this.db.prepare(` + INSERT INTO flags (id, key, config, env_id, created_at, updated_at) + VALUES (?, ?, ?, ?, datetime('now'), datetime('now')) + `); + + stmt.run(id, input.key, input.config, input.envId); + return this.findFlagById(id as FlagId)!; + } + + /** + * Find flag by ID + */ + findFlagById(id: FlagId): Flag | null { + const stmt = this.db.prepare('SELECT * FROM flags WHERE id = ?'); + const row = stmt.get(id) as FlagRow | undefined; + return row ? mapFlagRow(row) : null; + } + + /** + * Find flag by key in environment + */ + findFlagByKey(envId: EnvironmentId, key: string): Flag | null { + const stmt = this.db.prepare(` + SELECT * FROM flags + WHERE env_id = ? AND key = ? + `); + const row = stmt.get(envId, key) as FlagRow | undefined; + return row ? mapFlagRow(row) : null; + } + + /** + * List all flags in an environment + */ + listFlagsByEnvironment(envId: EnvironmentId): Flag[] { + const stmt = this.db.prepare(` + SELECT * FROM flags + WHERE env_id = ? + ORDER BY created_at DESC + `); + const rows = stmt.all(envId) as FlagRow[]; + return rows.map(mapFlagRow); + } + + /** + * Update flag + */ + updateFlag(id: FlagId, input: UpdateFlagInput): Flag | null { + const stmt = this.db.prepare(` + UPDATE flags + SET config = ?, updated_at = datetime('now') + WHERE id = ? + `); + + stmt.run(input.config, id); + return this.findFlagById(id); + } + + /** + * Delete flag + */ + deleteFlag(id: FlagId): boolean { + const stmt = this.db.prepare('DELETE FROM flags WHERE id = ?'); + const result = stmt.run(id); + return result.changes > 0; + } + + // ========================================================================== + // UTILITY + // ========================================================================== + + /** + * Generate a cryptographically secure API key + */ + private generateApiKey(): string { + return `tk_${crypto.randomBytes(32).toString('hex')}`; + } + + /** + * Get the raw database instance (use with caution) + */ + getRawDb(): Database.Database { + return this.db; + } +} + +/** + * Get or create singleton database client + */ +export function getDatabaseClient(): DatabaseClient { + // Check for test override first + if ((global as any).__testDb) { + return (global as any).__testDb; + } + + if (!dbInstance) { + const dbPath = process.env.DATABASE_URL?.replace('file:', '') || './dev.db'; + dbInstance = new DatabaseClient(dbPath); + } + return dbInstance; +} + +/** + * Set database client for testing + */ +export function setDatabaseClient(client: DatabaseClient | null): void { + (global as any).__testDb = client; +} + +/** + * Close the database client + */ +export function closeDatabaseClient(): void { + if (dbInstance) { + dbInstance.close(); + dbInstance = null; + } + (global as any).__testDb = null; +} + +/** + * Reset the database client (for testing) + */ +export function resetDatabaseClient(): void { + closeDatabaseClient(); +} diff --git a/apps/api/src/db/migrations.ts b/apps/api/src/db/migrations.ts new file mode 100644 index 0000000..7cce4b3 --- /dev/null +++ b/apps/api/src/db/migrations.ts @@ -0,0 +1,159 @@ +/** + * Database migration system + * Simple, explicit SQL migrations with version tracking + */ + +import Database from 'better-sqlite3'; + +export interface Migration { + version: number; + name: string; + up: string; + down?: string; +} + +/** + * All migrations in order + */ +export const migrations: Migration[] = [ + { + version: 1, + name: 'initial_schema', + up: ` + -- Projects table + CREATE TABLE IF NOT EXISTS projects ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + -- Environments table + CREATE TABLE IF NOT EXISTS environments ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + api_key TEXT NOT NULL UNIQUE, + project_id TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE + ); + + -- Flags table + CREATE TABLE IF NOT EXISTS flags ( + id TEXT PRIMARY KEY, + key TEXT NOT NULL, + config TEXT NOT NULL, + env_id TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + FOREIGN KEY (env_id) REFERENCES environments(id) ON DELETE CASCADE + ); + + -- Indexes + CREATE UNIQUE INDEX IF NOT EXISTS idx_env_project_name + ON environments(project_id, name); + + CREATE UNIQUE INDEX IF NOT EXISTS idx_flag_env_key + ON flags(env_id, key); + + CREATE INDEX IF NOT EXISTS idx_flag_env_id + ON flags(env_id); + + CREATE INDEX IF NOT EXISTS idx_env_api_key + ON environments(api_key); + + -- Migration version tracking + CREATE TABLE IF NOT EXISTS schema_migrations ( + version INTEGER PRIMARY KEY, + name TEXT NOT NULL, + applied_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + `, + down: ` + DROP TABLE IF EXISTS flags; + DROP TABLE IF EXISTS environments; + DROP TABLE IF EXISTS projects; + DROP TABLE IF EXISTS schema_migrations; + `, + }, +]; + +/** + * Get current database version + */ +function getCurrentVersion(db: Database.Database): number { + try { + const row = db + .prepare('SELECT MAX(version) as version FROM schema_migrations') + .get() as { version: number | null }; + return row.version ?? 0; + } catch { + // Table doesn't exist yet + return 0; + } +} + +/** + * Run pending migrations + */ +export function runMigrations(db: Database.Database): void { + const currentVersion = getCurrentVersion(db); + const pendingMigrations = migrations.filter((m) => m.version > currentVersion); + + if (pendingMigrations.length === 0) { + return; + } + + console.log(`Running ${pendingMigrations.length} pending migration(s)...`); + + for (const migration of pendingMigrations) { + console.log(` Applying migration ${migration.version}: ${migration.name}`); + + // Run migration in a transaction + const migrate = db.transaction(() => { + db.exec(migration.up); + db + .prepare( + 'INSERT INTO schema_migrations (version, name) VALUES (?, ?)' + ) + .run(migration.version, migration.name); + }); + + migrate(); + console.log(` ✓ Migration ${migration.version} applied`); + } + + console.log('All migrations applied successfully'); +} + +/** + * Initialize database with schema + */ +export function initializeDatabase(db: Database.Database): void { + // Enable WAL mode for better concurrency + db.pragma('journal_mode = WAL'); + + // Enable foreign keys + db.pragma('foreign_keys = ON'); + + // Run migrations + runMigrations(db); +} + +/** + * Reset database (for testing) + */ +export function resetDatabase(db: Database.Database): void { + // Drop all tables + db.exec(` + DROP TABLE IF EXISTS flags; + DROP TABLE IF EXISTS environments; + DROP TABLE IF EXISTS projects; + DROP TABLE IF EXISTS schema_migrations; + `); + + // Reinitialize + initializeDatabase(db); +} + diff --git a/apps/api/src/db/types.ts b/apps/api/src/db/types.ts new file mode 100644 index 0000000..7d89c70 --- /dev/null +++ b/apps/api/src/db/types.ts @@ -0,0 +1,214 @@ +/** + * Comprehensive type-safe database types + * Provides branded types, row types, and domain types for the entire database + */ + +// ============================================================================ +// BRANDED TYPES - Prevent mixing different ID types +// ============================================================================ + +declare const __brand: unique symbol; +type Brand = T & { readonly [__brand]: TBrand }; + +export type ProjectId = Brand; +export type EnvironmentId = Brand; +export type FlagId = Brand; +export type ApiKey = Brand; + +// Type guards for branded types +export function isProjectId(value: string): value is ProjectId { + return typeof value === 'string' && value.length > 0; +} + +export function isEnvironmentId(value: string): value is EnvironmentId { + return typeof value === 'string' && value.length > 0; +} + +export function isFlagId(value: string): value is FlagId { + return typeof value === 'string' && value.length > 0; +} + +export function isApiKey(value: string): value is ApiKey { + return typeof value === 'string' && value.length > 0; +} + +// ============================================================================ +// DATABASE ROW TYPES - Matches SQLite schema exactly +// ============================================================================ + +/** + * Project table row (direct from SQLite) + */ +export interface ProjectRow { + id: string; + name: string; + created_at: string; // ISO string in SQLite + updated_at: string; // ISO string in SQLite +} + +/** + * Environment table row (direct from SQLite) + */ +export interface EnvironmentRow { + id: string; + name: string; + api_key: string; + project_id: string; + created_at: string; + updated_at: string; +} + +/** + * Flag table row (direct from SQLite) + */ +export interface FlagRow { + id: string; + key: string; + config: string; // JSON string + env_id: string; + created_at: string; + updated_at: string; +} + +// ============================================================================ +// DOMAIN TYPES - Application models with proper types +// ============================================================================ + +/** + * Project domain model + */ +export interface Project { + id: ProjectId; + name: string; + createdAt: Date; + updatedAt: Date; +} + +/** + * Environment domain model + */ +export interface Environment { + id: EnvironmentId; + name: string; + apiKey: ApiKey; + projectId: ProjectId; + createdAt: Date; + updatedAt: Date; +} + +/** + * Flag domain model + */ +export interface Flag { + id: FlagId; + key: string; + config: string; // JSON string (validated separately) + envId: EnvironmentId; + createdAt: Date; + updatedAt: Date; +} + +// ============================================================================ +// MAPPER FUNCTIONS - Convert between row and domain types +// ============================================================================ + +/** + * Convert ProjectRow to Project domain model + */ +export function mapProjectRow(row: ProjectRow): Project { + return { + id: row.id as ProjectId, + name: row.name, + createdAt: new Date(row.created_at), + updatedAt: new Date(row.updated_at), + }; +} + +/** + * Convert EnvironmentRow to Environment domain model + */ +export function mapEnvironmentRow(row: EnvironmentRow): Environment { + return { + id: row.id as EnvironmentId, + name: row.name, + apiKey: row.api_key as ApiKey, + projectId: row.project_id as ProjectId, + createdAt: new Date(row.created_at), + updatedAt: new Date(row.updated_at), + }; +} + +/** + * Convert FlagRow to Flag domain model + */ +export function mapFlagRow(row: FlagRow): Flag { + return { + id: row.id as FlagId, + key: row.key, + config: row.config, + envId: row.env_id as EnvironmentId, + createdAt: new Date(row.created_at), + updatedAt: new Date(row.updated_at), + }; +} + +// ============================================================================ +// INPUT TYPES - For creating/updating records +// ============================================================================ + +export interface CreateProjectInput { + name: string; +} + +export interface UpdateProjectInput { + name?: string; +} + +export interface CreateEnvironmentInput { + name: string; + projectId: ProjectId; +} + +export interface UpdateEnvironmentInput { + name?: string; +} + +export interface CreateFlagInput { + key: string; + config: string; // Must be valid JSON + envId: EnvironmentId; +} + +export interface UpdateFlagInput { + config: string; // Must be valid JSON +} + +// ============================================================================ +// QUERY RESULT TYPES +// ============================================================================ + +/** + * Result type for database operations that may fail + */ +export type DbResult = + | { success: true; data: T } + | { success: false; error: string }; + +/** + * Pagination parameters + */ +export interface PaginationParams { + limit?: number; + offset?: number; +} + +/** + * Paginated result + */ +export interface PaginatedResult { + data: T[]; + total: number; + limit: number; + offset: number; +} + diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 8deb849..c07083d 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -1 +1,51 @@ -// Placeholder for API implementation +/** + * Togglekit API Server + * Entry point for the Fastify application + */ + +import { createApp } from './app'; +import { closeDatabaseClient } from './db/client'; + +const PORT = parseInt(process.env.PORT || '3000', 10); +const HOST = process.env.HOST || '0.0.0.0'; + +async function start(): Promise { + try { + const app = await createApp({ + logger: true, + cors: { + origin: true, // Allow all origins in development + }, + }); + + // Start the server + await app.listen({ port: PORT, host: HOST }); + + app.log.info(`🚀 Togglekit API server listening on http://${HOST}:${PORT}`); + app.log.info(`📊 Health check: http://${HOST}:${PORT}/health`); + + // Graceful shutdown + const shutdown = async (signal: string): Promise => { + app.log.info(`${signal} received, shutting down gracefully...`); + + try { + await app.close(); + closeDatabaseClient(); + app.log.info('Server closed successfully'); + process.exit(0); + } catch (error) { + app.log.error(error, 'Error during shutdown'); + process.exit(1); + } + }; + + process.on('SIGTERM', () => shutdown('SIGTERM')); + process.on('SIGINT', () => shutdown('SIGINT')); + } catch (error) { + console.error('Failed to start server:', error); + process.exit(1); + } +} + +// Start the server +start(); diff --git a/apps/api/src/middleware/auth.ts b/apps/api/src/middleware/auth.ts new file mode 100644 index 0000000..66dd214 --- /dev/null +++ b/apps/api/src/middleware/auth.ts @@ -0,0 +1,83 @@ +/** + * Authentication middleware for API key validation + */ + +import { FastifyRequest, FastifyReply } from 'fastify'; +import { getDatabaseClient } from '../db/client'; +import { Environment } from '../db/types'; + +/** + * Extract API key from request query or body + */ +function extractApiKey(request: FastifyRequest): string | undefined { + // Check query params (for GET requests) + const query = request.query as Record; + if (query.apiKey && typeof query.apiKey === 'string') { + return query.apiKey; + } + + // Check body (for POST/PUT/DELETE requests) + const body = request.body as Record | undefined; + if (body?.apiKey && typeof body.apiKey === 'string') { + return body.apiKey; + } + + // Check Authorization header as fallback + const authHeader = request.headers.authorization; + if (authHeader?.startsWith('Bearer ')) { + return authHeader.substring(7); + } + + return undefined; +} + +/** + * Validate API key and resolve environment + * Attaches resolved environment to request object + */ +export async function authenticateApiKey( + request: FastifyRequest, + reply: FastifyReply +): Promise { + const apiKey = extractApiKey(request); + + if (!apiKey) { + return reply.status(401).send({ + error: 'Unauthorized', + message: 'API key required', + statusCode: 401, + }); + } + + try { + const db = getDatabaseClient(); + const environment = db.findEnvironmentByApiKey(apiKey); + + if (!environment) { + return reply.status(401).send({ + error: 'Unauthorized', + message: 'Invalid API key', + statusCode: 401, + }); + } + + // Attach resolved environment to request + (request as FastifyRequest & { environment: Environment }).environment = environment; + } catch (error) { + request.log.error(error, 'Failed to validate API key'); + return reply.status(500).send({ + error: 'Internal Server Error', + message: 'Failed to validate API key', + statusCode: 500, + }); + } +} + +/** + * Type guard to check if request has authenticated environment + */ +export function hasEnvironment( + request: FastifyRequest +): request is FastifyRequest & { environment: Environment } { + return 'environment' in request && !!request.environment; +} diff --git a/apps/api/src/routes/config.ts b/apps/api/src/routes/config.ts new file mode 100644 index 0000000..5c02cfb --- /dev/null +++ b/apps/api/src/routes/config.ts @@ -0,0 +1,66 @@ +/** + * Config endpoint for SDKs to fetch flag configurations + * GET /v1/config?apiKey= + */ + +import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import { FlagConfig } from '@togglekit/flags-core-ts'; +import { getDatabaseClient } from '../db/client'; +import { authenticateApiKey, hasEnvironment } from '../middleware/auth'; +import { ConfigRequest, ConfigResponse } from '../types/api'; + +export async function configRoutes(fastify: FastifyInstance): Promise { + /** + * GET /v1/config - Fetch all flags for an environment + * Used by SDKs to get the complete flag configuration + */ + fastify.get<{ + Querystring: ConfigRequest; + Reply: ConfigResponse; + }>( + '/v1/config', + { + preHandler: authenticateApiKey, + }, + async (request: FastifyRequest, reply: FastifyReply) => { + if (!hasEnvironment(request)) { + return reply.status(401).send({ + error: 'Unauthorized', + message: 'Authentication required', + statusCode: 401, + }); + } + + try { + const db = getDatabaseClient(); + const flags = db.listFlagsByEnvironment(request.environment.id); + + // Transform database records into FlagConfig format + const flagConfig: FlagConfig = {}; + for (const flag of flags) { + try { + // Parse the stored JSON config + const parsedConfig = JSON.parse(flag.config); + flagConfig[flag.key] = parsedConfig; + } catch (error) { + request.log.error( + { flagKey: flag.key, error }, + 'Failed to parse flag config' + ); + // Skip malformed flags + continue; + } + } + + return reply.send(flagConfig); + } catch (error) { + request.log.error(error, 'Failed to fetch config'); + return reply.status(500).send({ + error: 'Internal Server Error', + message: 'Failed to fetch config', + statusCode: 500, + }); + } + } + ); +} diff --git a/apps/api/src/routes/evaluate.ts b/apps/api/src/routes/evaluate.ts new file mode 100644 index 0000000..96c20a8 --- /dev/null +++ b/apps/api/src/routes/evaluate.ts @@ -0,0 +1,90 @@ +/** + * Server-side evaluation endpoint + * POST /v1/evaluate + */ + +import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import { Evaluator } from '@togglekit/flags-core-ts'; +import { getDatabaseClient } from '../db/client'; +import { authenticateApiKey, hasEnvironment } from '../middleware/auth'; +import { EvaluateRequest, EvaluateResponse } from '../types/api'; + +export async function evaluateRoutes(fastify: FastifyInstance): Promise { + /** + * POST /v1/evaluate - Evaluate a flag server-side + * Useful for server-to-server evaluation without needing to fetch entire config + */ + fastify.post<{ + Body: EvaluateRequest; + Reply: EvaluateResponse; + }>( + '/v1/evaluate', + { + preHandler: authenticateApiKey, + }, + async (request: FastifyRequest, reply: FastifyReply) => { + if (!hasEnvironment(request)) { + return reply.status(401).send({ + error: 'Unauthorized', + message: 'Authentication required', + statusCode: 401, + }); + } + + const body = request.body as EvaluateRequest; + + // Validate request + if (!body.flagKey || typeof body.flagKey !== 'string') { + return reply.status(400).send({ + error: 'Bad Request', + message: 'flagKey is required', + statusCode: 400, + }); + } + + if (!body.context || typeof body.context !== 'object') { + return reply.status(400).send({ + error: 'Bad Request', + message: 'context is required', + statusCode: 400, + }); + } + + try { + const db = getDatabaseClient(); + + // Fetch all flags for the environment + const flags = db.listFlagsByEnvironment(request.environment.id); + + // Build FlagConfig + const flagConfig: Record = {}; + for (const flag of flags) { + try { + flagConfig[flag.key] = JSON.parse(flag.config); + } catch (error) { + request.log.error( + { flagKey: flag.key, error }, + 'Failed to parse flag config' + ); + continue; + } + } + + // Evaluate using the core evaluator + const evaluator = new Evaluator(flagConfig); + + // Try boolean evaluation first + const result = evaluator.evalBool(body.flagKey, body.context); + + return reply.send(result); + } catch (error) { + request.log.error(error, 'Failed to evaluate flag'); + return reply.status(500).send({ + error: 'Internal Server Error', + message: 'Failed to evaluate flag', + statusCode: 500, + }); + } + } + ); +} diff --git a/apps/api/src/routes/flags.ts b/apps/api/src/routes/flags.ts new file mode 100644 index 0000000..52c7a2d --- /dev/null +++ b/apps/api/src/routes/flags.ts @@ -0,0 +1,361 @@ +/** + * CRUD endpoints for flag management + */ + +import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify'; +import { getDatabaseClient } from '../db/client'; +import { authenticateApiKey, hasEnvironment } from '../middleware/auth'; +import { FlagId } from '../db/types'; +import { + CreateFlagRequest, + UpdateFlagRequest, + ListFlagsRequest, + FlagResponse, +} from '../types/api'; + +export async function flagRoutes(fastify: FastifyInstance): Promise { + /** + * GET /v1/flags - List all flags in the environment + */ + fastify.get<{ + Querystring: ListFlagsRequest; + Reply: FlagResponse[]; + }>( + '/v1/flags', + { + preHandler: authenticateApiKey, + }, + async (request: FastifyRequest, reply: FastifyReply) => { + if (!hasEnvironment(request)) { + return reply.status(401).send({ + error: 'Unauthorized', + message: 'Authentication required', + statusCode: 401, + }); + } + + try { + const db = getDatabaseClient(); + const flags = db.listFlagsByEnvironment(request.environment.id); + + const response: FlagResponse[] = flags.map((flag) => ({ + id: flag.id, + key: flag.key, + config: flag.config, + envId: flag.envId, + createdAt: flag.createdAt.toISOString(), + updatedAt: flag.updatedAt.toISOString(), + })); + + return reply.send(response); + } catch (error) { + request.log.error(error, 'Failed to list flags'); + return reply.status(500).send({ + error: 'Internal Server Error', + message: 'Failed to list flags', + statusCode: 500, + }); + } + } + ); + + /** + * POST /v1/flags - Create a new flag + */ + fastify.post<{ + Body: CreateFlagRequest; + Reply: FlagResponse; + }>( + '/v1/flags', + { + preHandler: authenticateApiKey, + }, + async (request: FastifyRequest, reply: FastifyReply) => { + if (!hasEnvironment(request)) { + return reply.status(401).send({ + error: 'Unauthorized', + message: 'Authentication required', + statusCode: 401, + }); + } + + const body = request.body as CreateFlagRequest; + + // Validate required fields + if (!body.key || typeof body.key !== 'string') { + return reply.status(400).send({ + error: 'Bad Request', + message: 'Flag key is required', + statusCode: 400, + }); + } + + if (!body.config || typeof body.config !== 'string') { + return reply.status(400).send({ + error: 'Bad Request', + message: 'Flag config is required', + statusCode: 400, + }); + } + + // Validate config is valid JSON + try { + JSON.parse(body.config); + } catch { + return reply.status(400).send({ + error: 'Bad Request', + message: 'Flag config must be valid JSON', + statusCode: 400, + }); + } + + try { + const db = getDatabaseClient(); + + // Check if flag already exists + const existing = db.findFlagByKey(request.environment.id, body.key); + + if (existing) { + return reply.status(409).send({ + error: 'Conflict', + message: `Flag with key '${body.key}' already exists`, + statusCode: 409, + }); + } + + // Create the flag + const flag = db.createFlag({ + key: body.key, + config: body.config, + envId: request.environment.id, + }); + + const response: FlagResponse = { + id: flag.id, + key: flag.key, + config: flag.config, + envId: flag.envId, + createdAt: flag.createdAt.toISOString(), + updatedAt: flag.updatedAt.toISOString(), + }; + + return reply.status(201).send(response); + } catch (error) { + request.log.error(error, 'Failed to create flag'); + return reply.status(500).send({ + error: 'Internal Server Error', + message: 'Failed to create flag', + statusCode: 500, + }); + } + } + ); + + /** + * GET /v1/flags/:id - Get a specific flag + */ + fastify.get<{ + Params: { id: string }; + Querystring: { apiKey: string }; + Reply: FlagResponse; + }>( + '/v1/flags/:id', + { + preHandler: authenticateApiKey, + }, + async (request: FastifyRequest, reply: FastifyReply) => { + if (!hasEnvironment(request)) { + return reply.status(401).send({ + error: 'Unauthorized', + message: 'Authentication required', + statusCode: 401, + }); + } + + const params = request.params as { id: string }; + + try { + const db = getDatabaseClient(); + const flag = db.findFlagById(params.id as FlagId); + + if (!flag || flag.envId !== request.environment.id) { + return reply.status(404).send({ + error: 'Not Found', + message: 'Flag not found', + statusCode: 404, + }); + } + + const response: FlagResponse = { + id: flag.id, + key: flag.key, + config: flag.config, + envId: flag.envId, + createdAt: flag.createdAt.toISOString(), + updatedAt: flag.updatedAt.toISOString(), + }; + + return reply.send(response); + } catch (error) { + request.log.error(error, 'Failed to get flag'); + return reply.status(500).send({ + error: 'Internal Server Error', + message: 'Failed to get flag', + statusCode: 500, + }); + } + } + ); + + /** + * PUT /v1/flags/:id - Update a flag + */ + fastify.put<{ + Params: { id: string }; + Body: UpdateFlagRequest; + Reply: FlagResponse; + }>( + '/v1/flags/:id', + { + preHandler: authenticateApiKey, + }, + async (request: FastifyRequest, reply: FastifyReply) => { + if (!hasEnvironment(request)) { + return reply.status(401).send({ + error: 'Unauthorized', + message: 'Authentication required', + statusCode: 401, + }); + } + + const params = request.params as { id: string }; + const body = request.body as UpdateFlagRequest; + + // Validate config + if (!body.config || typeof body.config !== 'string') { + return reply.status(400).send({ + error: 'Bad Request', + message: 'Flag config is required', + statusCode: 400, + }); + } + + // Validate config is valid JSON + try { + JSON.parse(body.config); + } catch { + return reply.status(400).send({ + error: 'Bad Request', + message: 'Flag config must be valid JSON', + statusCode: 400, + }); + } + + try { + const db = getDatabaseClient(); + + // Check if flag exists and belongs to this environment + const existing = db.findFlagById(params.id as FlagId); + + if (!existing || existing.envId !== request.environment.id) { + return reply.status(404).send({ + error: 'Not Found', + message: 'Flag not found', + statusCode: 404, + }); + } + + // Update the flag + const flag = db.updateFlag(params.id as FlagId, { + config: body.config, + }); + + if (!flag) { + return reply.status(404).send({ + error: 'Not Found', + message: 'Flag not found', + statusCode: 404, + }); + } + + const response: FlagResponse = { + id: flag.id, + key: flag.key, + config: flag.config, + envId: flag.envId, + createdAt: flag.createdAt.toISOString(), + updatedAt: flag.updatedAt.toISOString(), + }; + + return reply.send(response); + } catch (error) { + request.log.error(error, 'Failed to update flag'); + return reply.status(500).send({ + error: 'Internal Server Error', + message: 'Failed to update flag', + statusCode: 500, + }); + } + } + ); + + /** + * DELETE /v1/flags/:id - Delete a flag + */ + fastify.delete<{ + Params: { id: string }; + Querystring: { apiKey: string }; + Reply: { success: boolean }; + }>( + '/v1/flags/:id', + { + preHandler: authenticateApiKey, + }, + async (request: FastifyRequest, reply: FastifyReply) => { + if (!hasEnvironment(request)) { + return reply.status(401).send({ + error: 'Unauthorized', + message: 'Authentication required', + statusCode: 401, + }); + } + + const params = request.params as { id: string }; + + try { + const db = getDatabaseClient(); + + // Check if flag exists and belongs to this environment + const existing = db.findFlagById(params.id as FlagId); + + if (!existing || existing.envId !== request.environment.id) { + return reply.status(404).send({ + error: 'Not Found', + message: 'Flag not found', + statusCode: 404, + }); + } + + // Delete the flag + const deleted = db.deleteFlag(params.id as FlagId); + + if (!deleted) { + return reply.status(404).send({ + error: 'Not Found', + message: 'Flag not found', + statusCode: 404, + }); + } + + return reply.send({ success: true }); + } catch (error) { + request.log.error(error, 'Failed to delete flag'); + return reply.status(500).send({ + error: 'Internal Server Error', + message: 'Failed to delete flag', + statusCode: 500, + }); + } + } + ); +} diff --git a/apps/api/src/types/api.ts b/apps/api/src/types/api.ts new file mode 100644 index 0000000..c0c913f --- /dev/null +++ b/apps/api/src/types/api.ts @@ -0,0 +1,81 @@ +/** + * API type definitions + */ + +import { FlagConfig, Context, EvaluationResult } from '@togglekit/flags-core-ts'; + +/** + * Request query for SDK config endpoint + */ +export interface ConfigRequest { + apiKey: string; +} + +/** + * Response for SDK config endpoint + */ +export type ConfigResponse = FlagConfig; + +/** + * Request body for flag creation + */ +export interface CreateFlagRequest { + key: string; + config: string; // JSON string of Flag definition + apiKey: string; +} + +/** + * Request body for flag update + */ +export interface UpdateFlagRequest { + config: string; // JSON string of Flag definition + apiKey: string; +} + +/** + * Request query for flag list + */ +export interface ListFlagsRequest { + apiKey: string; +} + +/** + * Flag response + */ +export interface FlagResponse { + id: string; + key: string; + config: string; + envId: string; + createdAt: string; + updatedAt: string; +} + +/** + * Request body for server-side evaluation + */ +export interface EvaluateRequest { + flagKey: string; + context: Context; + apiKey: string; +} + +/** + * Response for server-side evaluation + */ +export type EvaluateResponse = EvaluationResult; + +/** + * Error response + */ +export interface ErrorResponse { + error: string; + message: string; + statusCode: number; +} + +/** + * Environment with API key resolved (re-export for convenience) + */ +export type { Environment as ResolvedEnvironment } from '../db/types'; diff --git a/apps/api/test/api.test.ts b/apps/api/test/api.test.ts new file mode 100644 index 0000000..dcbc942 --- /dev/null +++ b/apps/api/test/api.test.ts @@ -0,0 +1,420 @@ +/** + * Integration tests for Togglekit API + * Tests all endpoints with a test database + */ + +import { FastifyInstance } from 'fastify'; +import { createApp } from '../src/app'; +import { DatabaseClient, setDatabaseClient, closeDatabaseClient } from '../src/db/client'; +import { EnvironmentId, ApiKey } from '../src/db/types'; + +describe('Togglekit API', () => { + let app: FastifyInstance; + let db: DatabaseClient; + let testApiKey: ApiKey; + let testEnvId: EnvironmentId; + + beforeAll(async () => { + // Create in-memory database for tests + db = new DatabaseClient(':memory:'); + + // Set as test database + setDatabaseClient(db); + + // Create app + app = await createApp({ logger: false }); + + // Create test project and environment + const project = db.createProject({ name: 'Test Project' }); + const environment = db.createEnvironment({ + name: 'development', + projectId: project.id, + }); + + testEnvId = environment.id; + testApiKey = environment.apiKey; + }); + + afterAll(async () => { + await app.close(); + db.close(); + closeDatabaseClient(); + }); + + afterEach(() => { + // Clean up flags after each test + const flags = db.listFlagsByEnvironment(testEnvId); + for (const flag of flags) { + db.deleteFlag(flag.id); + } + }); + + describe('Health Check', () => { + it('should return 200 OK', async () => { + const response = await app.inject({ + method: 'GET', + url: '/health', + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.status).toBe('ok'); + expect(body.timestamp).toBeDefined(); + }); + }); + + describe('Authentication', () => { + it('should reject requests without API key', async () => { + const response = await app.inject({ + method: 'GET', + url: '/v1/config', + }); + + expect(response.statusCode).toBe(401); + const body = JSON.parse(response.body); + expect(body.message).toBe('API key required'); + }); + + it('should reject requests with invalid API key', async () => { + const response = await app.inject({ + method: 'GET', + url: '/v1/config?apiKey=invalid-key', + }); + + expect(response.statusCode).toBe(401); + const body = JSON.parse(response.body); + expect(body.message).toBe('Invalid API key'); + }); + + it('should accept requests with valid API key', async () => { + const response = await app.inject({ + method: 'GET', + url: `/v1/config?apiKey=${testApiKey}`, + }); + + expect(response.statusCode).toBe(200); + }); + }); + + describe('GET /v1/config', () => { + it('should return empty config when no flags exist', async () => { + const response = await app.inject({ + method: 'GET', + url: `/v1/config?apiKey=${testApiKey}`, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body).toEqual({}); + }); + + it('should return all flags in FlagConfig format', async () => { + // Create test flag + db.createFlag({ + key: 'test-flag', + envId: testEnvId, + config: JSON.stringify({ + key: 'test-flag', + defaultValue: true, + rules: [], + }), + }); + + const response = await app.inject({ + method: 'GET', + url: `/v1/config?apiKey=${testApiKey}`, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body['test-flag']).toBeDefined(); + expect(body['test-flag'].key).toBe('test-flag'); + expect(body['test-flag'].defaultValue).toBe(true); + }); + }); + + describe('POST /v1/flags', () => { + it('should create a new flag', async () => { + const flagConfig = { + key: 'new-feature', + defaultValue: false, + description: 'Test feature flag', + }; + + const response = await app.inject({ + method: 'POST', + url: '/v1/flags', + payload: { + key: 'new-feature', + config: JSON.stringify(flagConfig), + apiKey: testApiKey, + }, + }); + + expect(response.statusCode).toBe(201); + const body = JSON.parse(response.body); + expect(body.key).toBe('new-feature'); + expect(body.envId).toBe(testEnvId); + expect(JSON.parse(body.config)).toEqual(flagConfig); + }); + + it('should reject duplicate flag keys', async () => { + const flagConfig = { + key: 'duplicate-flag', + defaultValue: false, + }; + + // Create first flag + await app.inject({ + method: 'POST', + url: '/v1/flags', + payload: { + key: 'duplicate-flag', + config: JSON.stringify(flagConfig), + apiKey: testApiKey, + }, + }); + + // Try to create duplicate + const response = await app.inject({ + method: 'POST', + url: '/v1/flags', + payload: { + key: 'duplicate-flag', + config: JSON.stringify(flagConfig), + apiKey: testApiKey, + }, + }); + + expect(response.statusCode).toBe(409); + const body = JSON.parse(response.body); + expect(body.message).toContain('already exists'); + }); + + it('should reject invalid JSON config', async () => { + const response = await app.inject({ + method: 'POST', + url: '/v1/flags', + payload: { + key: 'bad-config', + config: 'not valid json', + apiKey: testApiKey, + }, + }); + + expect(response.statusCode).toBe(400); + const body = JSON.parse(response.body); + expect(body.message).toContain('valid JSON'); + }); + }); + + describe('GET /v1/flags', () => { + it('should list all flags', async () => { + // Create test flags + db.createFlag({ + key: 'flag-1', + envId: testEnvId, + config: JSON.stringify({ key: 'flag-1', defaultValue: true }), + }); + db.createFlag({ + key: 'flag-2', + envId: testEnvId, + config: JSON.stringify({ key: 'flag-2', defaultValue: false }), + }); + + const response = await app.inject({ + method: 'GET', + url: `/v1/flags?apiKey=${testApiKey}`, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(Array.isArray(body)).toBe(true); + expect(body.length).toBe(2); + expect(body.some((f: { key: string }) => f.key === 'flag-1')).toBe(true); + expect(body.some((f: { key: string }) => f.key === 'flag-2')).toBe(true); + }); + }); + + describe('PUT /v1/flags/:id', () => { + it('should update flag config', async () => { + const flag = db.createFlag({ + key: 'update-test', + envId: testEnvId, + config: JSON.stringify({ key: 'update-test', defaultValue: true }), + }); + + const newConfig = { + key: 'update-test', + defaultValue: false, + description: 'Updated flag', + }; + + const response = await app.inject({ + method: 'PUT', + url: `/v1/flags/${flag.id}`, + payload: { + config: JSON.stringify(newConfig), + apiKey: testApiKey, + }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.id).toBe(flag.id); + expect(JSON.parse(body.config)).toEqual(newConfig); + }); + + it('should return 404 for non-existent flag', async () => { + const response = await app.inject({ + method: 'PUT', + url: '/v1/flags/non-existent-id', + payload: { + config: JSON.stringify({ key: 'test', defaultValue: true }), + apiKey: testApiKey, + }, + }); + + expect(response.statusCode).toBe(404); + }); + }); + + describe('DELETE /v1/flags/:id', () => { + it('should delete a flag', async () => { + const flag = db.createFlag({ + key: 'delete-test', + envId: testEnvId, + config: JSON.stringify({ key: 'delete-test', defaultValue: true }), + }); + + const response = await app.inject({ + method: 'DELETE', + url: `/v1/flags/${flag.id}?apiKey=${testApiKey}`, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.success).toBe(true); + + // Verify flag is deleted + const deletedFlag = db.findFlagById(flag.id); + expect(deletedFlag).toBeNull(); + }); + + it('should return 404 for non-existent flag', async () => { + const response = await app.inject({ + method: 'DELETE', + url: `/v1/flags/non-existent-id?apiKey=${testApiKey}`, + }); + + expect(response.statusCode).toBe(404); + }); + }); + + describe('POST /v1/evaluate', () => { + it('should evaluate flag with matching condition', async () => { + db.createFlag({ + key: 'eval-flag', + envId: testEnvId, + config: JSON.stringify({ + key: 'eval-flag', + defaultValue: false, + rules: [ + { + conditions: [ + { + attribute: 'email', + operator: 'eq', + value: 'test@example.com', + }, + ], + value: true, + }, + ], + }), + }); + + const response = await app.inject({ + method: 'POST', + url: '/v1/evaluate', + payload: { + flagKey: 'eval-flag', + context: { + userId: 'user-123', + attributes: { + email: 'test@example.com', + }, + }, + apiKey: testApiKey, + }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.value).toBe(true); + expect(body.reason).toBe('rule_match'); + }); + + it('should evaluate flag with default value', async () => { + db.createFlag({ + key: 'eval-flag-2', + envId: testEnvId, + config: JSON.stringify({ + key: 'eval-flag-2', + defaultValue: false, + rules: [ + { + conditions: [ + { + attribute: 'email', + operator: 'eq', + value: 'test@example.com', + }, + ], + value: true, + }, + ], + }), + }); + + const response = await app.inject({ + method: 'POST', + url: '/v1/evaluate', + payload: { + flagKey: 'eval-flag-2', + context: { + userId: 'user-123', + attributes: { + email: 'other@example.com', + }, + }, + apiKey: testApiKey, + }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.value).toBe(false); + expect(body.reason).toBe('default'); + }); + + it('should handle non-existent flag', async () => { + const response = await app.inject({ + method: 'POST', + url: '/v1/evaluate', + payload: { + flagKey: 'non-existent', + context: { + userId: 'user-123', + attributes: {}, + }, + apiKey: testApiKey, + }, + }); + + expect(response.statusCode).toBe(200); + const body = JSON.parse(response.body); + expect(body.reason).toBe('flag_not_found'); + }); + }); +}); diff --git a/apps/api/tsconfig.json b/apps/api/tsconfig.json new file mode 100644 index 0000000..33d93a6 --- /dev/null +++ b/apps/api/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "module": "commonjs", + "target": "ES2022", + "lib": ["ES2022"], + "moduleResolution": "node", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "test"] +} + diff --git a/m4-api-better-sqlite3.plan.md b/m4-api-better-sqlite3.plan.md new file mode 100644 index 0000000..6babba2 --- /dev/null +++ b/m4-api-better-sqlite3.plan.md @@ -0,0 +1,277 @@ +# M4 - API Service Implementation (better-sqlite3) + +## Overview + +Build the backend API service using **better-sqlite3** instead of Prisma for maximum simplicity, performance, and control. Maintains robust type safety through comprehensive TypeScript interfaces and type guards. + +## Why better-sqlite3? + +- **Simpler**: No code generation, no schema files, just SQL +- **Faster**: Synchronous API, no async overhead +- **Lighter**: ~1MB vs Prisma's ~3MB +- **More Control**: Direct SQL access, explicit queries +- **Perfect for self-hosted**: SQLite is ideal for single-server deployments + +## Type Safety Strategy + +Instead of Prisma's generated types, we'll use: +1. **Explicit TypeScript interfaces** for all database models +2. **Type-safe query builders** using prepared statements +3. **Runtime validation** for query results +4. **Branded types** for IDs to prevent mixing +5. **Zod schemas** for JSON config validation + +## Core Components + +### 1. Database Types (`src/db/types.ts`) + +```typescript +// Branded types for type safety +export type ProjectId = string & { readonly __brand: 'ProjectId' }; +export type EnvironmentId = string & { readonly __brand: 'EnvironmentId' }; +export type FlagId = string & { readonly __brand: 'FlagId' }; +export type ApiKey = string & { readonly __brand: 'ApiKey' }; + +// Database row types (matches SQLite schema exactly) +export interface ProjectRow { + id: string; + name: string; + created_at: string; + updated_at: string; +} + +export interface EnvironmentRow { + id: string; + name: string; + api_key: string; + project_id: string; + created_at: string; + updated_at: string; +} + +export interface FlagRow { + id: string; + key: string; + config: string; // JSON string + env_id: string; + created_at: string; + updated_at: string; +} + +// Domain types (converted from rows) +export interface Project { + id: ProjectId; + name: string; + createdAt: Date; + updatedAt: Date; +} + +export interface Environment { + id: EnvironmentId; + name: string; + apiKey: ApiKey; + projectId: ProjectId; + createdAt: Date; + updatedAt: Date; +} + +export interface Flag { + id: FlagId; + key: string; + config: string; + envId: EnvironmentId; + createdAt: Date; + updatedAt: Date; +} +``` + +### 2. Database Client (`src/db/client.ts`) + +Type-safe wrapper around better-sqlite3 with prepared statements: + +```typescript +import Database from 'better-sqlite3'; + +export class DatabaseClient { + private db: Database.Database; + + // Prepared statements (compiled once, reused) + private statements: { + findEnvironmentByApiKey: Database.Statement<[string]>; + findFlagsByEnvId: Database.Statement<[string]>; + createFlag: Database.Statement<[string, string, string, string]>; + // ... etc + }; + + constructor(dbPath: string) { + this.db = new Database(dbPath); + this.db.pragma('journal_mode = WAL'); + this.prepareStatements(); + } + + // Type-safe query methods + findEnvironmentByApiKey(apiKey: string): Environment | null { + const row = this.statements.findEnvironmentByApiKey.get(apiKey); + return row ? this.mapEnvironmentRow(row as EnvironmentRow) : null; + } + + // Type conversions with validation + private mapEnvironmentRow(row: EnvironmentRow): Environment { + return { + id: row.id as EnvironmentId, + name: row.name, + apiKey: row.api_key as ApiKey, + projectId: row.project_id as ProjectId, + createdAt: new Date(row.created_at), + updatedAt: new Date(row.updated_at), + }; + } +} +``` + +### 3. Migration System (`src/db/migrations.ts`) + +Simple, explicit migrations: + +```typescript +export interface Migration { + version: number; + name: string; + up: string; // SQL to apply + down: string; // SQL to rollback +} + +export const migrations: Migration[] = [ + { + version: 1, + name: 'initial_schema', + up: ` + CREATE TABLE projects (...); + CREATE TABLE environments (...); + CREATE TABLE flags (...); + `, + down: ` + DROP TABLE flags; + DROP TABLE environments; + DROP TABLE projects; + `, + }, +]; + +export function runMigrations(db: Database.Database): void { + // Check current version, run pending migrations +} +``` + +### 4. Query Builders (`src/db/queries.ts`) + +Type-safe query builders for complex operations: + +```typescript +export class FlagQueries { + constructor(private db: Database.Database) {} + + findByEnvId(envId: EnvironmentId): Flag[] { + const stmt = this.db.prepare(` + SELECT * FROM flags WHERE env_id = ? ORDER BY created_at DESC + `); + const rows = stmt.all(envId) as FlagRow[]; + return rows.map(mapFlagRow); + } + + create(data: { + key: string; + config: string; + envId: EnvironmentId; + }): Flag { + const id = crypto.randomUUID(); + const stmt = this.db.prepare(` + INSERT INTO flags (id, key, config, env_id, created_at, updated_at) + VALUES (?, ?, ?, ?, datetime('now'), datetime('now')) + `); + + stmt.run(id, data.key, data.config, data.envId); + + // Return created flag + return this.findById(id as FlagId)!; + } +} +``` + +### 5. Validation Layer (`src/db/validation.ts`) + +Runtime validation using Zod: + +```typescript +import { z } from 'zod'; +import { Flag as FlagType } from '@togglekit/flags-core-ts'; + +export const FlagConfigSchema = z.object({ + key: z.string(), + defaultValue: z.union([z.boolean(), z.string()]), + rules: z.array(z.any()).optional(), + variants: z.array(z.any()).optional(), + description: z.string().optional(), +}); + +export function validateFlagConfig(config: string): FlagType { + const parsed = JSON.parse(config); + return FlagConfigSchema.parse(parsed); +} +``` + +## Implementation Order + +1. **Remove Prisma** - Delete schema, migrations, client +2. **Add better-sqlite3** - Install package and types +3. **Create type system** - Comprehensive interfaces +4. **Create DB client** - Type-safe wrapper +5. **Create migrations** - Simple SQL-based system +6. **Create query builders** - For each entity +7. **Update auth middleware** - Use typed queries +8. **Update routes** - All endpoints with type safety +9. **Update tests** - In-memory SQLite testing +10. **Verify & test** - Build, run tests, manual testing + +## Dependencies + +```json +{ + "dependencies": { + "@togglekit/flags-core-ts": "workspace:*", + "fastify": "^4.25.2", + "@fastify/cors": "^8.5.0", + "better-sqlite3": "^9.2.2", + "zod": "^3.22.4" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.8", + "@types/node": "^20.11.0", + "tsx": "^4.7.0", + "typescript": "^5.3.3", + "jest": "^29.7.0", + "ts-jest": "^29.1.1" + } +} +``` + +## Benefits Over Prisma + +1. ✅ **Simpler** - No schema files, no generation +2. ✅ **Faster** - Synchronous, no overhead +3. ✅ **Explicit** - See exactly what SQL runs +4. ✅ **Lighter** - Smaller bundle +5. ✅ **Full Control** - Direct SQL access +6. ✅ **Type Safe** - Comprehensive TypeScript types +7. ✅ **No Magic** - Everything is explicit + +## Type Safety Guarantee + +Every database interaction is typed: +- ✅ Query inputs validated +- ✅ Query outputs typed +- ✅ IDs are branded types (can't mix ProjectId with FlagId) +- ✅ Dates properly converted +- ✅ JSON configs validated with Zod +- ✅ No `any` types in production code + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9fb5ba3..7ed63e8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -27,7 +27,45 @@ importers: specifier: ^5.3.3 version: 5.9.3 - apps/api: {} + apps/api: + dependencies: + '@fastify/cors': + specifier: ^8.5.0 + version: 8.5.0 + '@togglekit/flags-core-ts': + specifier: workspace:* + version: link:../../packages/flags-core-ts + better-sqlite3: + specifier: ^9.2.2 + version: 9.6.0 + fastify: + specifier: ^4.25.2 + version: 4.29.1 + zod: + specifier: ^3.22.4 + version: 3.25.76 + devDependencies: + '@types/better-sqlite3': + specifier: ^7.6.8 + version: 7.6.13 + '@types/jest': + specifier: ^29.5.11 + version: 29.5.14 + '@types/node': + specifier: ^20.11.0 + version: 20.19.25 + jest: + specifier: ^29.7.0 + version: 29.7.0(@types/node@20.19.25) + ts-jest: + specifier: ^29.1.1 + version: 29.4.6(@babel/core@7.28.5)(jest@29.7.0)(typescript@5.9.3) + tsx: + specifier: ^4.7.0 + version: 4.21.0 + typescript: + specifier: ^5.3.3 + version: 5.9.3 apps/dashboard: {} @@ -483,6 +521,240 @@ packages: resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} dev: true + /@esbuild/aix-ppc64@0.27.0: + resolution: {integrity: sha512-KuZrd2hRjz01y5JK9mEBSD3Vj3mbCvemhT466rSuJYeE/hjuBrHfjjcjMdTm/sz7au+++sdbJZJmuBwQLuw68A==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + requiresBuild: true + dev: true + optional: true + + /@esbuild/android-arm64@0.27.0: + resolution: {integrity: sha512-CC3vt4+1xZrs97/PKDkl0yN7w8edvU2vZvAFGD16n9F0Cvniy5qvzRXjfO1l94efczkkQE6g1x0i73Qf5uthOQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@esbuild/android-arm@0.27.0: + resolution: {integrity: sha512-j67aezrPNYWJEOHUNLPj9maeJte7uSMM6gMoxfPC9hOg8N02JuQi/T7ewumf4tNvJadFkvLZMlAq73b9uwdMyQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@esbuild/android-x64@0.27.0: + resolution: {integrity: sha512-wurMkF1nmQajBO1+0CJmcN17U4BP6GqNSROP8t0X/Jiw2ltYGLHpEksp9MpoBqkrFR3kv2/te6Sha26k3+yZ9Q==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@esbuild/darwin-arm64@0.27.0: + resolution: {integrity: sha512-uJOQKYCcHhg07DL7i8MzjvS2LaP7W7Pn/7uA0B5S1EnqAirJtbyw4yC5jQ5qcFjHK9l6o/MX9QisBg12kNkdHg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@esbuild/darwin-x64@0.27.0: + resolution: {integrity: sha512-8mG6arH3yB/4ZXiEnXof5MK72dE6zM9cDvUcPtxhUZsDjESl9JipZYW60C3JGreKCEP+p8P/72r69m4AZGJd5g==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@esbuild/freebsd-arm64@0.27.0: + resolution: {integrity: sha512-9FHtyO988CwNMMOE3YIeci+UV+x5Zy8fI2qHNpsEtSF83YPBmE8UWmfYAQg6Ux7Gsmd4FejZqnEUZCMGaNQHQw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/freebsd-x64@0.27.0: + resolution: {integrity: sha512-zCMeMXI4HS/tXvJz8vWGexpZj2YVtRAihHLk1imZj4efx1BQzN76YFeKqlDr3bUWI26wHwLWPd3rwh6pe4EV7g==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-arm64@0.27.0: + resolution: {integrity: sha512-AS18v0V+vZiLJyi/4LphvBE+OIX682Pu7ZYNsdUHyUKSoRwdnOsMf6FDekwoAFKej14WAkOef3zAORJgAtXnlQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-arm@0.27.0: + resolution: {integrity: sha512-t76XLQDpxgmq2cNXKTVEB7O7YMb42atj2Re2Haf45HkaUpjM2J0UuJZDuaGbPbamzZ7bawyGFUkodL+zcE+jvQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-ia32@0.27.0: + resolution: {integrity: sha512-Mz1jxqm/kfgKkc/KLHC5qIujMvnnarD9ra1cEcrs7qshTUSksPihGrWHVG5+osAIQ68577Zpww7SGapmzSt4Nw==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-loong64@0.27.0: + resolution: {integrity: sha512-QbEREjdJeIreIAbdG2hLU1yXm1uu+LTdzoq1KCo4G4pFOLlvIspBm36QrQOar9LFduavoWX2msNFAAAY9j4BDg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-mips64el@0.27.0: + resolution: {integrity: sha512-sJz3zRNe4tO2wxvDpH/HYJilb6+2YJxo/ZNbVdtFiKDufzWq4JmKAiHy9iGoLjAV7r/W32VgaHGkk35cUXlNOg==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-ppc64@0.27.0: + resolution: {integrity: sha512-z9N10FBD0DCS2dmSABDBb5TLAyF1/ydVb+N4pi88T45efQ/w4ohr/F/QYCkxDPnkhkp6AIpIcQKQ8F0ANoA2JA==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-riscv64@0.27.0: + resolution: {integrity: sha512-pQdyAIZ0BWIC5GyvVFn5awDiO14TkT/19FTmFcPdDec94KJ1uZcmFs21Fo8auMXzD4Tt+diXu1LW1gHus9fhFQ==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-s390x@0.27.0: + resolution: {integrity: sha512-hPlRWR4eIDDEci953RI1BLZitgi5uqcsjKMxwYfmi4LcwyWo2IcRP+lThVnKjNtk90pLS8nKdroXYOqW+QQH+w==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-x64@0.27.0: + resolution: {integrity: sha512-1hBWx4OUJE2cab++aVZ7pObD6s+DK4mPGpemtnAORBvb5l/g5xFGk0vc0PjSkrDs0XaXj9yyob3d14XqvnQ4gw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/netbsd-arm64@0.27.0: + resolution: {integrity: sha512-6m0sfQfxfQfy1qRuecMkJlf1cIzTOgyaeXaiVaaki8/v+WB+U4hc6ik15ZW6TAllRlg/WuQXxWj1jx6C+dfy3w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/netbsd-x64@0.27.0: + resolution: {integrity: sha512-xbbOdfn06FtcJ9d0ShxxvSn2iUsGd/lgPIO2V3VZIPDbEaIj1/3nBBe1AwuEZKXVXkMmpr6LUAgMkLD/4D2PPA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/openbsd-arm64@0.27.0: + resolution: {integrity: sha512-fWgqR8uNbCQ/GGv0yhzttj6sU/9Z5/Sv/VGU3F5OuXK6J6SlriONKrQ7tNlwBrJZXRYk5jUhuWvF7GYzGguBZQ==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/openbsd-x64@0.27.0: + resolution: {integrity: sha512-aCwlRdSNMNxkGGqQajMUza6uXzR/U0dIl1QmLjPtRbLOx3Gy3otfFu/VjATy4yQzo9yFDGTxYDo1FfAD9oRD2A==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/openharmony-arm64@0.27.0: + resolution: {integrity: sha512-nyvsBccxNAsNYz2jVFYwEGuRRomqZ149A39SHWk4hV0jWxKM0hjBPm3AmdxcbHiFLbBSwG6SbpIcUbXjgyECfA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + requiresBuild: true + dev: true + optional: true + + /@esbuild/sunos-x64@0.27.0: + resolution: {integrity: sha512-Q1KY1iJafM+UX6CFEL+F4HRTgygmEW568YMqDA5UV97AuZSm21b7SXIrRJDwXWPzr8MGr75fUZPV67FdtMHlHA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-arm64@0.27.0: + resolution: {integrity: sha512-W1eyGNi6d+8kOmZIwi/EDjrL9nxQIQ0MiGqe/AWc6+IaHloxHSGoeRgDRKHFISThLmsewZ5nHFvGFWdBYlgKPg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-ia32@0.27.0: + resolution: {integrity: sha512-30z1aKL9h22kQhilnYkORFYt+3wp7yZsHWus+wSKAJR8JtdfI76LJ4SBdMsCopTR3z/ORqVu5L1vtnHZWVj4cQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-x64@0.27.0: + resolution: {integrity: sha512-aIitBcjQeyOhMTImhLZmtxfdOcuNRpwlPNmlFKPcHQYPhEssw75Cl1TSXJXpMkzaua9FUetx/4OQKq7eJul5Cg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true + /@eslint-community/eslint-utils@4.9.0(eslint@8.57.1): resolution: {integrity: sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -520,6 +792,37 @@ packages: engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dev: true + /@fastify/ajv-compiler@3.6.0: + resolution: {integrity: sha512-LwdXQJjmMD+GwLOkP7TVC68qa+pSSogeWWmznRJ/coyTcfe9qA05AHFSe1eZFwK6q+xVRpChnvFUkf1iYaSZsQ==} + dependencies: + ajv: 8.17.1 + ajv-formats: 2.1.1(ajv@8.17.1) + fast-uri: 2.4.0 + dev: false + + /@fastify/cors@8.5.0: + resolution: {integrity: sha512-/oZ1QSb02XjP0IK1U0IXktEsw/dUBTxJOW7IpIeO8c/tNalw/KjoNSJv1Sf6eqoBPO+TDGkifq6ynFK3v68HFQ==} + dependencies: + fastify-plugin: 4.5.1 + mnemonist: 0.39.6 + dev: false + + /@fastify/error@3.4.1: + resolution: {integrity: sha512-wWSvph+29GR783IhmvdwWnN4bUxTD01Vm5Xad4i7i1VuAOItLvbPAb69sb0IQ2N57yprvhNIwAP5B6xfKTmjmQ==} + dev: false + + /@fastify/fast-json-stringify-compiler@4.3.0: + resolution: {integrity: sha512-aZAXGYo6m22Fk1zZzEUKBvut/CIIQe/BapEORnxiD5Qr0kPHqqI69NtEMCme74h+at72sPhbkb4ZrLd1W3KRLA==} + dependencies: + fast-json-stringify: 5.16.1 + dev: false + + /@fastify/merge-json-schemas@0.1.1: + resolution: {integrity: sha512-fERDVz7topgNjtXsJTTW1JKLy0rhuLRcquYqNR9rF7OcVpCa2OVW49ZPDIhaRRCaUuvVxI+N416xUoF76HNSXA==} + dependencies: + fast-deep-equal: 3.1.3 + dev: false + /@humanwhocodes/config-array@0.13.0: resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} engines: {node: '>=10.10.0'} @@ -925,6 +1228,10 @@ packages: dev: true optional: true + /@pinojs/redact@0.4.0: + resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} + dev: false + /@sinclair/typebox@0.27.8: resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} dev: true @@ -1044,6 +1351,12 @@ packages: '@babel/types': 7.28.5 dev: true + /@types/better-sqlite3@7.6.13: + resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} + dependencies: + '@types/node': 20.19.25 + dev: true + /@types/graceful-fs@4.1.9: resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==} dependencies: @@ -1292,6 +1605,10 @@ packages: deprecated: Use your platform's native atob() and btoa() methods instead dev: true + /abstract-logging@2.0.1: + resolution: {integrity: sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==} + dev: false + /acorn-globals@7.0.1: resolution: {integrity: sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==} dependencies: @@ -1329,6 +1646,28 @@ packages: - supports-color dev: true + /ajv-formats@2.1.1(ajv@8.17.1): + resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + dependencies: + ajv: 8.17.1 + dev: false + + /ajv-formats@3.0.1(ajv@8.17.1): + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + dependencies: + ajv: 8.17.1 + dev: false + /ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} dependencies: @@ -1338,6 +1677,15 @@ packages: uri-js: 4.4.1 dev: true + /ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + dev: false + /ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -1408,6 +1756,11 @@ packages: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} dev: true + /atomic-sleep@1.0.0: + resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==} + engines: {node: '>=8.0.0'} + dev: false + /available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} @@ -1415,6 +1768,13 @@ packages: possible-typed-array-names: 1.1.0 dev: true + /avvio@8.4.0: + resolution: {integrity: sha512-CDSwaxINFy59iNwhYnkvALBwZiTydGkOecZyPkqBpABYR1KqGEsET0VOOYDwtleZSUIdeY36DC2bSZ24CO1igA==} + dependencies: + '@fastify/error': 3.4.1 + fastq: 1.19.1 + dev: false + /axios@1.13.2: resolution: {integrity: sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==} dependencies: @@ -1506,20 +1866,32 @@ packages: /base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} - dev: true /baseline-browser-mapping@2.8.32: resolution: {integrity: sha512-OPz5aBThlyLFgxyhdwf/s2+8ab3OvT7AdTNvKHBwpXomIYeXqpUUuT8LrdtxZSsWJ4R4CU1un4XGh5Ez3nlTpw==} hasBin: true dev: true + /better-sqlite3@9.6.0: + resolution: {integrity: sha512-yR5HATnqeYNVnkaUTf4bOP2dJSnyhP4puJN/QPRyx4YkBEEUxib422n2XzPqDEHjQQqazoYoADdAm5vE15+dAQ==} + requiresBuild: true + dependencies: + bindings: 1.5.0 + prebuild-install: 7.1.3 + dev: false + + /bindings@1.5.0: + resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} + dependencies: + file-uri-to-path: 1.0.0 + dev: false + /bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} dependencies: buffer: 5.7.1 inherits: 2.0.4 readable-stream: 3.6.2 - dev: true /brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -1575,7 +1947,6 @@ packages: dependencies: base64-js: 1.5.1 ieee754: 1.2.1 - dev: true /call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} @@ -1635,6 +2006,10 @@ packages: engines: {node: '>=10'} dev: true + /chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + dev: false + /ci-info@3.9.0: resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} engines: {node: '>=8'} @@ -1705,6 +2080,11 @@ packages: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} dev: true + /cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + dev: false + /create-jest@29.7.0(@types/node@20.19.25): resolution: {integrity: sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -1781,6 +2161,13 @@ packages: resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} dev: true + /decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + dependencies: + mimic-response: 3.1.0 + dev: false + /dedent@1.7.0: resolution: {integrity: sha512-HGFtf8yhuhGhqO07SV79tRp+br4MnbdjeVxotpn1QBl30pcLLCQjX5b2295ll0fv8RKDKsmWYrl05usHM9CewQ==} peerDependencies: @@ -1814,6 +2201,11 @@ packages: which-typed-array: 1.1.19 dev: true + /deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + dev: false + /deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} dev: true @@ -1857,6 +2249,11 @@ packages: engines: {node: '>=0.4.0'} dev: true + /detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + dev: false + /detect-newline@3.1.0: resolution: {integrity: sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==} engines: {node: '>=8'} @@ -1937,7 +2334,6 @@ packages: resolution: {integrity: sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==} dependencies: once: 1.4.0 - dev: true /enquirer@2.3.6: resolution: {integrity: sha512-yjNnPr315/FjS4zIsUxYguYUPP2e1NK4d7E7ZOLiyYCcbFBiTMyID+2wvm2w6+pZ/odMA7cRkjhsPbltwBOrLg==} @@ -1998,6 +2394,40 @@ packages: hasown: 2.0.2 dev: true + /esbuild@0.27.0: + resolution: {integrity: sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA==} + engines: {node: '>=18'} + hasBin: true + requiresBuild: true + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.0 + '@esbuild/android-arm': 0.27.0 + '@esbuild/android-arm64': 0.27.0 + '@esbuild/android-x64': 0.27.0 + '@esbuild/darwin-arm64': 0.27.0 + '@esbuild/darwin-x64': 0.27.0 + '@esbuild/freebsd-arm64': 0.27.0 + '@esbuild/freebsd-x64': 0.27.0 + '@esbuild/linux-arm': 0.27.0 + '@esbuild/linux-arm64': 0.27.0 + '@esbuild/linux-ia32': 0.27.0 + '@esbuild/linux-loong64': 0.27.0 + '@esbuild/linux-mips64el': 0.27.0 + '@esbuild/linux-ppc64': 0.27.0 + '@esbuild/linux-riscv64': 0.27.0 + '@esbuild/linux-s390x': 0.27.0 + '@esbuild/linux-x64': 0.27.0 + '@esbuild/netbsd-arm64': 0.27.0 + '@esbuild/netbsd-x64': 0.27.0 + '@esbuild/openbsd-arm64': 0.27.0 + '@esbuild/openbsd-x64': 0.27.0 + '@esbuild/openharmony-arm64': 0.27.0 + '@esbuild/sunos-x64': 0.27.0 + '@esbuild/win32-arm64': 0.27.0 + '@esbuild/win32-ia32': 0.27.0 + '@esbuild/win32-x64': 0.27.0 + dev: true + /escalade@3.2.0: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} @@ -2150,6 +2580,11 @@ packages: engines: {node: '>= 0.8.0'} dev: true + /expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + dev: false + /expect@29.7.0: resolution: {integrity: sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -2161,9 +2596,16 @@ packages: jest-util: 29.7.0 dev: true + /fast-content-type-parse@1.1.0: + resolution: {integrity: sha512-fBHHqSTFLVnR61C+gltJuE5GkVQMV0S2nqUO8TJ+5Z3qAKG8vAx4FKai1s5jq/inV1+sREynIWSuQ6HgoSXpDQ==} + dev: false + + /fast-decode-uri-component@1.0.1: + resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + dev: false + /fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - dev: true /fast-glob@3.3.3: resolution: {integrity: sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==} @@ -2180,15 +2622,65 @@ packages: resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} dev: true + /fast-json-stringify@5.16.1: + resolution: {integrity: sha512-KAdnLvy1yu/XrRtP+LJnxbBGrhN+xXu+gt3EUvZhYGKCr3lFHq/7UFJHHFgmJKoqlh6B40bZLEv7w46B0mqn1g==} + dependencies: + '@fastify/merge-json-schemas': 0.1.1 + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + fast-deep-equal: 3.1.3 + fast-uri: 2.4.0 + json-schema-ref-resolver: 1.0.1 + rfdc: 1.4.1 + dev: false + /fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} dev: true + /fast-querystring@1.1.2: + resolution: {integrity: sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==} + dependencies: + fast-decode-uri-component: 1.0.1 + dev: false + + /fast-uri@2.4.0: + resolution: {integrity: sha512-ypuAmmMKInk5q7XcepxlnUWDLWv4GFtaJqAzWKqn62IpQ3pejtr5dTVbt3vwqVaMKmkNR55sTT+CqUKIaT21BA==} + dev: false + + /fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + dev: false + + /fastify-plugin@4.5.1: + resolution: {integrity: sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==} + dev: false + + /fastify@4.29.1: + resolution: {integrity: sha512-m2kMNHIG92tSNWv+Z3UeTR9AWLLuo7KctC7mlFPtMEVrfjIhmQhkQnT9v15qA/BfVq3vvj134Y0jl9SBje3jXQ==} + dependencies: + '@fastify/ajv-compiler': 3.6.0 + '@fastify/error': 3.4.1 + '@fastify/fast-json-stringify-compiler': 4.3.0 + abstract-logging: 2.0.1 + avvio: 8.4.0 + fast-content-type-parse: 1.1.0 + fast-json-stringify: 5.16.1 + find-my-way: 8.2.2 + light-my-request: 5.14.0 + pino: 9.14.0 + process-warning: 3.0.0 + proxy-addr: 2.0.7 + rfdc: 1.4.1 + secure-json-parse: 2.7.0 + semver: 7.7.3 + toad-cache: 3.7.0 + dev: false + /fastq@1.19.1: resolution: {integrity: sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ==} dependencies: reusify: 1.1.0 - dev: true /fb-watchman@2.0.2: resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==} @@ -2210,6 +2702,10 @@ packages: flat-cache: 3.2.0 dev: true + /file-uri-to-path@1.0.0: + resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} + dev: false + /fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -2217,6 +2713,15 @@ packages: to-regex-range: 5.0.1 dev: true + /find-my-way@8.2.2: + resolution: {integrity: sha512-Dobi7gcTEq8yszimcfp/R7+owiT4WncAJ7VTTgFH1jYJ5GaG1FbhjwDG820hptN0QDFvzVY3RfCzdInvGPGzjA==} + engines: {node: '>=14'} + dependencies: + fast-deep-equal: 3.1.3 + fast-querystring: 1.1.2 + safe-regex2: 3.1.0 + dev: false + /find-up@4.1.0: resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} engines: {node: '>=8'} @@ -2279,9 +2784,13 @@ packages: mime-types: 2.1.35 dev: true + /forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + dev: false + /fs-constants@1.0.0: resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} - dev: true /fs-extra@11.3.2: resolution: {integrity: sha512-Xr9F6z6up6Ws+NjzMCZc6WXg2YFRlrLP9NQDO3VQrWrfiojdhS56TzueT88ze0uBdCTwEIhQ3ptnmKeWGFAe0A==} @@ -2356,6 +2865,16 @@ packages: engines: {node: '>=10'} dev: true + /get-tsconfig@4.13.0: + resolution: {integrity: sha512-1VKTZJCwBrvbd+Wn3AOgQP/2Av+TfTCOlE4AcRJE72W1ksZXbAx8PPBR9RzgTeSPzlPMHrbANMH3LbltH73wxQ==} + dependencies: + resolve-pkg-maps: 1.0.0 + dev: true + + /github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + dev: false + /glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -2508,7 +3027,6 @@ packages: /ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - dev: true /ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} @@ -2552,7 +3070,10 @@ packages: /inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - dev: true + + /ini@1.3.8: + resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==} + dev: false /internal-slot@1.1.0: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} @@ -2563,6 +3084,11 @@ packages: side-channel: 1.1.0 dev: true + /ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + dev: false + /is-arguments@1.2.0: resolution: {integrity: sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==} engines: {node: '>= 0.4'} @@ -3329,10 +3855,20 @@ packages: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} dev: true + /json-schema-ref-resolver@1.0.1: + resolution: {integrity: sha512-EJAj1pgHc1hxF6vo2Z3s69fMjO1INq6eGHXZ8Z6wCQeldCuwxGK9Sxf4/cScGn3FZubCVUehfWtcDM/PLteCQw==} + dependencies: + fast-deep-equal: 3.1.3 + dev: false + /json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} dev: true + /json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + dev: false + /json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} dev: true @@ -3379,6 +3915,14 @@ packages: type-check: 0.4.0 dev: true + /light-my-request@5.14.0: + resolution: {integrity: sha512-aORPWntbpH5esaYpGOOmri0OHDOe3wC5M2MQxZ9dvMLZm6DnaAn0kJlcbU9hwsQgLzmZyReKwFwwPkR+nHu5kA==} + dependencies: + cookie: 0.7.2 + process-warning: 3.0.0 + set-cookie-parser: 2.7.2 + dev: false + /lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} dev: true @@ -3492,6 +4036,11 @@ packages: engines: {node: '>=6'} dev: true + /mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + dev: false + /min-indent@1.0.1: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} @@ -3512,12 +4061,25 @@ packages: /minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} - dev: true + + /mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + dev: false + + /mnemonist@0.39.6: + resolution: {integrity: sha512-A/0v5Z59y63US00cRSLiloEIw3t5G+MiKz4BhX21FI+YBJXBOGW0ohFxTxO08dsOYlzxo87T7vGfZKYp2bcAWA==} + dependencies: + obliterator: 2.0.5 + dev: false /ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} dev: true + /napi-build-utils@2.0.0: + resolution: {integrity: sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==} + dev: false + /natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} dev: true @@ -3526,6 +4088,13 @@ packages: resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} dev: true + /node-abi@3.85.0: + resolution: {integrity: sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==} + engines: {node: '>=10'} + dependencies: + semver: 7.7.3 + dev: false + /node-int64@0.4.0: resolution: {integrity: sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw==} dev: true @@ -3646,11 +4215,19 @@ packages: object-keys: 1.1.1 dev: true + /obliterator@2.0.5: + resolution: {integrity: sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==} + dev: false + + /on-exit-leak-free@2.1.2: + resolution: {integrity: sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==} + engines: {node: '>=14.0.0'} + dev: false + /once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} dependencies: wrappy: 1.0.2 - dev: true /onetime@5.1.2: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} @@ -3783,6 +4360,33 @@ packages: engines: {node: '>=8.6'} dev: true + /pino-abstract-transport@2.0.0: + resolution: {integrity: sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==} + dependencies: + split2: 4.2.0 + dev: false + + /pino-std-serializers@7.0.0: + resolution: {integrity: sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==} + dev: false + + /pino@9.14.0: + resolution: {integrity: sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w==} + hasBin: true + dependencies: + '@pinojs/redact': 0.4.0 + atomic-sleep: 1.0.0 + on-exit-leak-free: 2.1.2 + pino-abstract-transport: 2.0.0 + pino-std-serializers: 7.0.0 + process-warning: 5.0.0 + quick-format-unescaped: 4.0.4 + real-require: 0.2.0 + safe-stable-stringify: 2.5.0 + sonic-boom: 4.2.0 + thread-stream: 3.1.0 + dev: false + /pirates@4.0.7: resolution: {integrity: sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==} engines: {node: '>= 6'} @@ -3800,6 +4404,25 @@ packages: engines: {node: '>= 0.4'} dev: true + /prebuild-install@7.1.3: + resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} + engines: {node: '>=10'} + hasBin: true + dependencies: + detect-libc: 2.1.2 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 2.0.0 + node-abi: 3.85.0 + pump: 3.0.3 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.4 + tunnel-agent: 0.6.0 + dev: false + /prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -3823,6 +4446,14 @@ packages: react-is: 18.3.1 dev: true + /process-warning@3.0.0: + resolution: {integrity: sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==} + dev: false + + /process-warning@5.0.0: + resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + dev: false + /prompts@2.4.2: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} @@ -3831,6 +4462,14 @@ packages: sisteransi: 1.0.5 dev: true + /proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + dev: false + /proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} dev: true @@ -3841,6 +4480,13 @@ packages: punycode: 2.3.1 dev: true + /pump@3.0.3: + resolution: {integrity: sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==} + dependencies: + end-of-stream: 1.4.5 + once: 1.4.0 + dev: false + /punycode@2.3.1: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} @@ -3858,6 +4504,20 @@ packages: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} dev: true + /quick-format-unescaped@4.0.4: + resolution: {integrity: sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==} + dev: false + + /rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + dev: false + /react-dom@18.3.1(react@18.3.1): resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} peerDependencies: @@ -3900,7 +4560,11 @@ packages: inherits: 2.0.4 string_decoder: 1.3.0 util-deprecate: 1.0.2 - dev: true + + /real-require@0.2.0: + resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} + engines: {node: '>= 12.13.0'} + dev: false /redent@3.0.0: resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} @@ -3927,6 +4591,11 @@ packages: engines: {node: '>=0.10.0'} dev: true + /require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + dev: false + /requires-port@1.0.0: resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} dev: true @@ -3948,6 +4617,10 @@ packages: engines: {node: '>=8'} dev: true + /resolve-pkg-maps@1.0.0: + resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + dev: true + /resolve.exports@2.0.3: resolution: {integrity: sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==} engines: {node: '>=10'} @@ -3971,10 +4644,18 @@ packages: signal-exit: 3.0.7 dev: true + /ret@0.4.3: + resolution: {integrity: sha512-0f4Memo5QP7WQyUEAYUO3esD/XjOc3Zjjg5CPsAq1p8sIu0XPeMbHJemKA0BO7tV0X7+A0FoEpbmHXWxPyD3wQ==} + engines: {node: '>=10'} + dev: false + /reusify@1.1.0: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} - dev: true + + /rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + dev: false /rimraf@3.0.2: resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} @@ -3992,7 +4673,6 @@ packages: /safe-buffer@5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} - dev: true /safe-regex-test@1.1.0: resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==} @@ -4003,6 +4683,17 @@ packages: is-regex: 1.2.1 dev: true + /safe-regex2@3.1.0: + resolution: {integrity: sha512-RAAZAGbap2kBfbVhvmnTFv73NWLMvDGOITFYTZBAaY8eR+Ir4ef7Up/e7amo+y1+AH+3PtLkrt9mvcTsG9LXug==} + dependencies: + ret: 0.4.3 + dev: false + + /safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + dev: false + /safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} dev: true @@ -4020,6 +4711,10 @@ packages: loose-envify: 1.4.0 dev: true + /secure-json-parse@2.7.0: + resolution: {integrity: sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==} + dev: false + /semver@6.3.1: resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} hasBin: true @@ -4029,7 +4724,10 @@ packages: resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==} engines: {node: '>=10'} hasBin: true - dev: true + + /set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + dev: false /set-function-length@1.2.2: resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} @@ -4109,6 +4807,18 @@ packages: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} dev: true + /simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + dev: false + + /simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + dev: false + /sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} dev: true @@ -4118,6 +4828,12 @@ packages: engines: {node: '>=8'} dev: true + /sonic-boom@4.2.0: + resolution: {integrity: sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==} + dependencies: + atomic-sleep: 1.0.0 + dev: false + /source-map-support@0.5.13: resolution: {integrity: sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==} dependencies: @@ -4130,6 +4846,11 @@ packages: engines: {node: '>=0.10.0'} dev: true + /split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + dev: false + /sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} dev: true @@ -4170,7 +4891,6 @@ packages: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} dependencies: safe-buffer: 5.2.1 - dev: true /strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} @@ -4201,6 +4921,11 @@ packages: min-indent: 1.0.1 dev: true + /strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + dev: false + /strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -4239,6 +4964,15 @@ packages: resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} dev: true + /tar-fs@2.1.4: + resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.3 + tar-stream: 2.2.0 + dev: false + /tar-stream@2.2.0: resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} engines: {node: '>=6'} @@ -4248,7 +4982,6 @@ packages: fs-constants: 1.0.0 inherits: 2.0.4 readable-stream: 3.6.2 - dev: true /test-exclude@6.0.0: resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} @@ -4263,6 +4996,12 @@ packages: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} dev: true + /thread-stream@3.1.0: + resolution: {integrity: sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==} + dependencies: + real-require: 0.2.0 + dev: false + /through@2.3.8: resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==} dev: true @@ -4283,6 +5022,11 @@ packages: is-number: 7.0.0 dev: true + /toad-cache@3.7.0: + resolution: {integrity: sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==} + engines: {node: '>=12'} + dev: false + /tough-cookie@4.1.4: resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} engines: {node: '>=6'} @@ -4363,6 +5107,23 @@ packages: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} dev: true + /tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + dependencies: + esbuild: 0.27.0 + get-tsconfig: 4.13.0 + optionalDependencies: + fsevents: 2.3.3 + dev: true + + /tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} + dependencies: + safe-buffer: 5.2.1 + dev: false + /type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -4444,7 +5205,6 @@ packages: /util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} - dev: true /v8-to-istanbul@9.3.0: resolution: {integrity: sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==} @@ -4561,7 +5321,6 @@ packages: /wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} - dev: true /write-file-atomic@4.0.2: resolution: {integrity: sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==} @@ -4624,3 +5383,7 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} dev: true + + /zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} + dev: false