diff --git a/.github/workflows/quality-metrics.yml b/.github/workflows/quality-metrics.yml index 0e697d1a3..70beaaa5a 100644 --- a/.github/workflows/quality-metrics.yml +++ b/.github/workflows/quality-metrics.yml @@ -63,6 +63,11 @@ jobs: run: npx tsx ../../tools/check-maintainability.ts > maintainability-report.json continue-on-error: true + - name: Detect stub implementations + id: stub-detection + run: npx tsx ../../tools/detect-stub-implementations.ts > stub-report.json + continue-on-error: true + - name: Upload quality reports uses: actions/upload-artifact@v4 if: always() @@ -72,6 +77,7 @@ jobs: frontends/nextjs/complexity-report.json frontends/nextjs/function-metrics.json frontends/nextjs/maintainability-report.json + frontends/nextjs/stub-report.json retention-days: 30 # ============================================================================ diff --git a/dbal/api/schema/operations/package.ops.yaml b/dbal/api/schema/operations/package.ops.yaml index e698c3455..a89553f23 100644 --- a/dbal/api/schema/operations/package.ops.yaml +++ b/dbal/api/schema/operations/package.ops.yaml @@ -12,6 +12,20 @@ operations: errors: - CONFLICT: "Package with name and version already exists" - VALIDATION_ERROR: "Invalid package input" + + create_many: + description: "Bulk create package definitions" + input: + required: [items] + optional: [] + output: integer + acl_required: ["package:create"] + validation: + - semver_format: "Version must be valid semver" + - name_version_unique: "Package name+version must be unique" + errors: + - CONFLICT: "Package with name and version already exists" + - VALIDATION_ERROR: "Invalid package input" read: description: "Get package by ID" @@ -35,6 +49,17 @@ operations: - NOT_FOUND: "Package not found" - CONFLICT: "Package name+version already exists" - VALIDATION_ERROR: "Invalid package update" + + update_many: + description: "Bulk update packages matching a filter" + input: + required: [filter, data] + output: integer + acl_required: ["package:update"] + validation: + - semver_format: "Version must be valid semver" + errors: + - VALIDATION_ERROR: "Invalid package update" delete: description: "Delete package" @@ -44,6 +69,15 @@ operations: acl_required: ["package:delete"] errors: - NOT_FOUND: "Package not found" + + delete_many: + description: "Bulk delete packages matching a filter" + input: + required: [filter] + output: integer + acl_required: ["package:delete"] + errors: + - VALIDATION_ERROR: "Invalid delete filter" list: description: "List packages with filtering and pagination" diff --git a/dbal/api/schema/operations/user.ops.yaml b/dbal/api/schema/operations/user.ops.yaml index aec6d7aa5..4349813d1 100644 --- a/dbal/api/schema/operations/user.ops.yaml +++ b/dbal/api/schema/operations/user.ops.yaml @@ -13,6 +13,21 @@ operations: errors: - CONFLICT: "Username or email already exists" - VALIDATION_ERROR: "Invalid input data" + + create_many: + description: "Bulk create user accounts" + input: + required: [items] + optional: [] + output: integer + acl_required: ["user:create"] + validation: + - username_unique: "Usernames must be unique" + - email_unique: "Emails must be unique" + - email_format: "Each user must have a valid email address" + errors: + - CONFLICT: "Username or email already exists" + - VALIDATION_ERROR: "Invalid user input" read: description: "Get user by ID" @@ -39,6 +54,17 @@ operations: - NOT_FOUND: "User not found" - FORBIDDEN: "Cannot update other user" - CONFLICT: "Username or email already exists" + + update_many: + description: "Bulk update users matching a filter" + input: + required: [filter, data] + output: integer + acl_required: ["user:update"] + validation: + - no_role_escalation: "Cannot elevate roles in bulk updates" + errors: + - VALIDATION_ERROR: "Invalid update payload" delete: description: "Delete user account" @@ -50,6 +76,15 @@ operations: errors: - NOT_FOUND: "User not found" - FORBIDDEN: "Insufficient permissions" + + delete_many: + description: "Bulk delete users matching a filter" + input: + required: [filter] + output: integer + acl_required: ["user:delete"] + errors: + - VALIDATION_ERROR: "Invalid delete filter" list: description: "List users with filtering and pagination" diff --git a/dbal/cpp/README.md b/dbal/cpp/README.md index 422db5929..a2c41922c 100644 --- a/dbal/cpp/README.md +++ b/dbal/cpp/README.md @@ -311,13 +311,21 @@ performance: ### Batch Operations -Use batch APIs for bulk inserts: +Use batch APIs for bulk operations (return count of affected rows): ```cpp std::vector users = {...}; -auto result = client.batchCreateUsers(users); +auto created = client.batchCreateUsers(users); + +std::vector updates = {...}; +auto updated = client.batchUpdateUsers(updates); + +std::vector ids = {...}; +auto deleted = client.batchDeleteUsers(ids); ``` +Package equivalents are available via `batchCreatePackages`, `batchUpdatePackages`, and `batchDeletePackages`. + ## Security Hardening ### 1. Run as Non-Root diff --git a/dbal/cpp/include/dbal/client.hpp b/dbal/cpp/include/dbal/client.hpp index 8f57c9762..7a56a1b94 100644 --- a/dbal/cpp/include/dbal/client.hpp +++ b/dbal/cpp/include/dbal/client.hpp @@ -32,6 +32,9 @@ public: Result updateUser(const std::string& id, const UpdateUserInput& input); Result deleteUser(const std::string& id); Result> listUsers(const ListOptions& options); + Result batchCreateUsers(const std::vector& inputs); + Result batchUpdateUsers(const std::vector& updates); + Result batchDeleteUsers(const std::vector& ids); Result createPage(const CreatePageInput& input); Result getPage(const std::string& id); @@ -63,6 +66,9 @@ public: Result updatePackage(const std::string& id, const UpdatePackageInput& input); Result deletePackage(const std::string& id); Result> listPackages(const ListOptions& options); + Result batchCreatePackages(const std::vector& inputs); + Result batchUpdatePackages(const std::vector& updates); + Result batchDeletePackages(const std::vector& ids); void close(); diff --git a/dbal/cpp/include/dbal/types.hpp b/dbal/cpp/include/dbal/types.hpp index 99c15e8bc..0a58f0004 100644 --- a/dbal/cpp/include/dbal/types.hpp +++ b/dbal/cpp/include/dbal/types.hpp @@ -40,6 +40,11 @@ struct UpdateUserInput { std::optional role; }; +struct UpdateUserBatchItem { + std::string id; + UpdateUserInput data; +}; + struct PageView { std::string id; std::string slug; @@ -194,6 +199,11 @@ struct UpdatePackageInput { std::optional installed_by; }; +struct UpdatePackageBatchItem { + std::string id; + UpdatePackageInput data; +}; + struct ListOptions { std::map filter; std::map sort; diff --git a/dbal/cpp/src/client.cpp b/dbal/cpp/src/client.cpp index 0537d3238..d9b82e50d 100644 --- a/dbal/cpp/src/client.cpp +++ b/dbal/cpp/src/client.cpp @@ -259,6 +259,61 @@ Result> Client::listUsers(const ListOptions& options) { return Result>(std::vector()); } +Result Client::batchCreateUsers(const std::vector& inputs) { + if (inputs.empty()) { + return Result(0); + } + + std::vector created_ids; + for (const auto& input : inputs) { + auto result = createUser(input); + if (result.isError()) { + auto& store = getStore(); + for (const auto& id : created_ids) { + store.users.erase(id); + } + return result.error(); + } + created_ids.push_back(result.value().id); + } + + return Result(static_cast(created_ids.size())); +} + +Result Client::batchUpdateUsers(const std::vector& updates) { + if (updates.empty()) { + return Result(0); + } + + int updated = 0; + for (const auto& item : updates) { + auto result = updateUser(item.id, item.data); + if (result.isError()) { + return result.error(); + } + updated++; + } + + return Result(updated); +} + +Result Client::batchDeleteUsers(const std::vector& ids) { + if (ids.empty()) { + return Result(0); + } + + int deleted = 0; + for (const auto& id : ids) { + auto result = deleteUser(id); + if (result.isError()) { + return result.error(); + } + deleted++; + } + + return Result(deleted); +} + Result Client::createPage(const CreatePageInput& input) { // Validation if (!isValidSlug(input.slug)) { @@ -670,6 +725,13 @@ Result Client::getSession(const std::string& id) { return Error::notFound("Session not found: " + id); } + auto now = std::chrono::system_clock::now(); + if (it->second.expires_at <= now) { + store.session_tokens.erase(it->second.token); + store.sessions.erase(it); + return Error::notFound("Session expired: " + id); + } + return Result(it->second); } @@ -741,6 +803,24 @@ Result Client::deleteSession(const std::string& id) { Result> Client::listSessions(const ListOptions& options) { auto& store = getStore(); + auto now = std::chrono::system_clock::now(); + std::vector expired_sessions; + + for (const auto& [id, session] : store.sessions) { + if (session.expires_at <= now) { + expired_sessions.push_back(id); + } + } + + for (const auto& id : expired_sessions) { + auto expired_it = store.sessions.find(id); + if (expired_it == store.sessions.end()) { + continue; + } + store.session_tokens.erase(expired_it->second.token); + store.sessions.erase(expired_it); + } + std::vector sessions; for (const auto& [id, session] : store.sessions) { @@ -1159,6 +1239,65 @@ Result> Client::listPackages(const ListOptions& options) { return Result>(std::vector()); } +Result Client::batchCreatePackages(const std::vector& inputs) { + if (inputs.empty()) { + return Result(0); + } + + std::vector created_ids; + for (const auto& input : inputs) { + auto result = createPackage(input); + if (result.isError()) { + auto& store = getStore(); + for (const auto& id : created_ids) { + auto it = store.packages.find(id); + if (it != store.packages.end()) { + store.package_keys.erase(packageKey(it->second.name, it->second.version)); + store.packages.erase(it); + } + } + return result.error(); + } + created_ids.push_back(result.value().id); + } + + return Result(static_cast(created_ids.size())); +} + +Result Client::batchUpdatePackages(const std::vector& updates) { + if (updates.empty()) { + return Result(0); + } + + int updated = 0; + for (const auto& item : updates) { + auto result = updatePackage(item.id, item.data); + if (result.isError()) { + return result.error(); + } + updated++; + } + + return Result(updated); +} + +Result Client::batchDeletePackages(const std::vector& ids) { + if (ids.empty()) { + return Result(0); + } + + int deleted = 0; + for (const auto& id : ids) { + auto result = deletePackage(id); + if (result.isError()) { + return result.error(); + } + deleted++; + } + + return Result(deleted); +} + void Client::close() { // For in-memory implementation, optionally clear store // auto& store = getStore(); diff --git a/dbal/cpp/tests/unit/client_test.cpp b/dbal/cpp/tests/unit/client_test.cpp index 15ed9202d..e4863e2dc 100644 --- a/dbal/cpp/tests/unit/client_test.cpp +++ b/dbal/cpp/tests/unit/client_test.cpp @@ -248,6 +248,63 @@ void test_list_users() { std::cout << " ✓ Pagination works (page 1, limit 2)" << std::endl; } +void test_user_batch_operations() { + std::cout << "Testing user batch operations..." << std::endl; + + dbal::ClientConfig config; + config.adapter = "sqlite"; + config.database_url = ":memory:"; + dbal::Client client(config); + + std::vector users; + dbal::CreateUserInput user1; + user1.username = "batch_user_1"; + user1.email = "batch_user_1@example.com"; + users.push_back(user1); + + dbal::CreateUserInput user2; + user2.username = "batch_user_2"; + user2.email = "batch_user_2@example.com"; + user2.role = dbal::UserRole::Admin; + users.push_back(user2); + + auto createResult = client.batchCreateUsers(users); + assert(createResult.isOk()); + assert(createResult.value() == 2); + std::cout << " ✓ Batch created users" << std::endl; + + dbal::ListOptions listOptions; + listOptions.limit = 10; + auto listResult = client.listUsers(listOptions); + assert(listResult.isOk()); + assert(listResult.value().size() >= 2); + + std::vector updates; + dbal::UpdateUserBatchItem update1; + update1.id = listResult.value()[0].id; + update1.data.email = "batch_updated_1@example.com"; + updates.push_back(update1); + + dbal::UpdateUserBatchItem update2; + update2.id = listResult.value()[1].id; + update2.data.role = dbal::UserRole::God; + updates.push_back(update2); + + auto updateResult = client.batchUpdateUsers(updates); + assert(updateResult.isOk()); + assert(updateResult.value() == 2); + std::cout << " ✓ Batch updated users" << std::endl; + + std::vector ids; + ids.push_back(listResult.value()[0].id); + ids.push_back(listResult.value()[1].id); + + auto deleteResult = client.batchDeleteUsers(ids); + assert(deleteResult.isOk()); + assert(deleteResult.value() == 2); + std::cout << " ✓ Batch deleted users" << std::endl; +} + void test_page_crud() { std::cout << "Testing page CRUD operations..." << std::endl; @@ -736,6 +793,66 @@ void test_package_validation() { std::cout << " ✓ Duplicate package version rejected" << std::endl; } +void test_package_batch_operations() { + std::cout << "Testing package batch operations..." << std::endl; + + dbal::ClientConfig config; + config.adapter = "sqlite"; + config.database_url = ":memory:"; + dbal::Client client(config); + + std::vector packages; + dbal::CreatePackageInput package1; + package1.name = "batch-package-1"; + package1.version = "1.0.0"; + package1.author = "MetaBuilder"; + package1.manifest = {{"entry", "index.lua"}}; + packages.push_back(package1); + + dbal::CreatePackageInput package2; + package2.name = "batch-package-2"; + package2.version = "2.0.0"; + package2.author = "MetaBuilder"; + package2.manifest = {{"entry", "chat.lua"}}; + packages.push_back(package2); + + auto createResult = client.batchCreatePackages(packages); + assert(createResult.isOk()); + assert(createResult.value() == 2); + std::cout << " ✓ Batch created packages" << std::endl; + + dbal::ListOptions listOptions; + listOptions.limit = 10; + auto listResult = client.listPackages(listOptions); + assert(listResult.isOk()); + assert(listResult.value().size() >= 2); + + std::vector updates; + dbal::UpdatePackageBatchItem update1; + update1.id = listResult.value()[0].id; + update1.data.is_installed = true; + updates.push_back(update1); + + dbal::UpdatePackageBatchItem update2; + update2.id = listResult.value()[1].id; + update2.data.is_installed = true; + updates.push_back(update2); + + auto updateResult = client.batchUpdatePackages(updates); + assert(updateResult.isOk()); + assert(updateResult.value() == 2); + std::cout << " ✓ Batch updated packages" << std::endl; + + std::vector ids; + ids.push_back(listResult.value()[0].id); + ids.push_back(listResult.value()[1].id); + + auto deleteResult = client.batchDeletePackages(ids); + assert(deleteResult.isOk()); + assert(deleteResult.value() == 2); + std::cout << " ✓ Batch deleted packages" << std::endl; +} + void test_error_handling() { std::cout << "Testing comprehensive error handling..." << std::endl; @@ -773,6 +890,7 @@ int main() { test_update_user(); test_delete_user(); test_list_users(); + test_user_batch_operations(); test_page_crud(); test_page_validation(); test_workflow_crud(); @@ -783,11 +901,12 @@ int main() { test_lua_script_validation(); test_package_crud(); test_package_validation(); + test_package_batch_operations(); test_error_handling(); std::cout << std::endl; std::cout << "==================================================" << std::endl; - std::cout << "✅ All 20 test suites passed!" << std::endl; + std::cout << "✅ All 22 test suites passed!" << std::endl; std::cout << "==================================================" << std::endl; return 0; } catch (const std::exception& e) { diff --git a/dbal/ts/package.json b/dbal/ts/package.json index 756a87e00..43fda65d2 100644 --- a/dbal/ts/package.json +++ b/dbal/ts/package.json @@ -27,16 +27,16 @@ "author": "MetaBuilder Contributors", "license": "MIT", "dependencies": { - "@prisma/client": "^6.3.1", - "zod": "^3.22.4" + "@prisma/client": "^6.19.1", + "zod": "^4.2.1" }, "devDependencies": { - "@types/node": "^20.10.0", - "eslint": "^8.56.0", - "prettier": "^3.1.1", - "tsx": "^4.7.0", - "typescript": "^5.3.3", - "vitest": "^1.1.0", - "@vitest/coverage-v8": "^1.1.0" + "@types/node": "^25.0.3", + "eslint": "^9.39.2", + "prettier": "^3.7.4", + "tsx": "^4.21.0", + "typescript": "^5.9.3", + "vitest": "^4.0.16", + "@vitest/coverage-v8": "^4.0.16" } } diff --git a/dbal/ts/src/adapters/acl-adapter.ts b/dbal/ts/src/adapters/acl-adapter.ts index 2e9e17269..6c7dd61e5 100644 --- a/dbal/ts/src/adapters/acl-adapter.ts +++ b/dbal/ts/src/adapters/acl-adapter.ts @@ -84,6 +84,24 @@ export class ACLAdapter implements DBALAdapter { this.auditLog = options?.auditLog ?? true } + private resolvePermissionOperation(operation: string): string { + switch (operation) { + case 'findFirst': + case 'findByField': + return 'read' + case 'createMany': + return 'create' + case 'updateByField': + case 'updateMany': + return 'update' + case 'deleteByField': + case 'deleteMany': + return 'delete' + default: + return operation + } + } + private checkPermission(entity: string, operation: string): void { const matchingRules = this.rules.filter(rule => rule.entity === entity && @@ -244,6 +262,187 @@ export class ACLAdapter implements DBALAdapter { } } + async findFirst(entity: string, filter?: Record): Promise { + const permissionOperation = this.resolvePermissionOperation('findFirst') + this.checkPermission(entity, permissionOperation) + + try { + const result = await this.baseAdapter.findFirst(entity, filter) + if (result) { + this.checkRowLevelAccess(entity, permissionOperation, result as Record) + } + if (this.auditLog) { + this.logAudit(entity, 'findFirst', true) + } + return result + } catch (error) { + if (this.auditLog) { + this.logAudit(entity, 'findFirst', false, error instanceof Error ? error.message : 'Unknown error') + } + throw error + } + } + + async findByField(entity: string, field: string, value: unknown): Promise { + const permissionOperation = this.resolvePermissionOperation('findByField') + this.checkPermission(entity, permissionOperation) + + try { + const result = await this.baseAdapter.findByField(entity, field, value) + if (result) { + this.checkRowLevelAccess(entity, permissionOperation, result as Record) + } + if (this.auditLog) { + this.logAudit(entity, 'findByField', true) + } + return result + } catch (error) { + if (this.auditLog) { + this.logAudit(entity, 'findByField', false, error instanceof Error ? error.message : 'Unknown error') + } + throw error + } + } + + async upsert( + entity: string, + uniqueField: string, + uniqueValue: unknown, + createData: Record, + updateData: Record + ): Promise { + try { + const existing = await this.baseAdapter.findByField(entity, uniqueField, uniqueValue) + if (existing) { + this.checkPermission(entity, 'update') + this.checkRowLevelAccess(entity, 'update', existing as Record) + } else { + this.checkPermission(entity, 'create') + } + + const result = await this.baseAdapter.upsert(entity, uniqueField, uniqueValue, createData, updateData) + if (this.auditLog) { + this.logAudit(entity, 'upsert', true) + } + return result + } catch (error) { + if (this.auditLog) { + this.logAudit(entity, 'upsert', false, error instanceof Error ? error.message : 'Unknown error') + } + throw error + } + } + + async updateByField(entity: string, field: string, value: unknown, data: Record): Promise { + const permissionOperation = this.resolvePermissionOperation('updateByField') + this.checkPermission(entity, permissionOperation) + + const existing = await this.baseAdapter.findByField(entity, field, value) + if (existing) { + this.checkRowLevelAccess(entity, permissionOperation, existing as Record) + } + + try { + const result = await this.baseAdapter.updateByField(entity, field, value, data) + if (this.auditLog) { + this.logAudit(entity, 'updateByField', true) + } + return result + } catch (error) { + if (this.auditLog) { + this.logAudit(entity, 'updateByField', false, error instanceof Error ? error.message : 'Unknown error') + } + throw error + } + } + + async deleteByField(entity: string, field: string, value: unknown): Promise { + const permissionOperation = this.resolvePermissionOperation('deleteByField') + this.checkPermission(entity, permissionOperation) + + const existing = await this.baseAdapter.findByField(entity, field, value) + if (existing) { + this.checkRowLevelAccess(entity, permissionOperation, existing as Record) + } + + try { + const result = await this.baseAdapter.deleteByField(entity, field, value) + if (this.auditLog) { + this.logAudit(entity, 'deleteByField', true) + } + return result + } catch (error) { + if (this.auditLog) { + this.logAudit(entity, 'deleteByField', false, error instanceof Error ? error.message : 'Unknown error') + } + throw error + } + } + + async createMany(entity: string, data: Record[]): Promise { + const permissionOperation = this.resolvePermissionOperation('createMany') + this.checkPermission(entity, permissionOperation) + + try { + const result = await this.baseAdapter.createMany(entity, data) + if (this.auditLog) { + this.logAudit(entity, 'createMany', true) + } + return result + } catch (error) { + if (this.auditLog) { + this.logAudit(entity, 'createMany', false, error instanceof Error ? error.message : 'Unknown error') + } + throw error + } + } + + async updateMany(entity: string, filter: Record, data: Record): Promise { + const permissionOperation = this.resolvePermissionOperation('updateMany') + this.checkPermission(entity, permissionOperation) + + const listResult = await this.baseAdapter.list(entity, { filter }) + for (const item of listResult.data) { + this.checkRowLevelAccess(entity, permissionOperation, item as Record) + } + + try { + const result = await this.baseAdapter.updateMany(entity, filter, data) + if (this.auditLog) { + this.logAudit(entity, 'updateMany', true) + } + return result + } catch (error) { + if (this.auditLog) { + this.logAudit(entity, 'updateMany', false, error instanceof Error ? error.message : 'Unknown error') + } + throw error + } + } + + async deleteMany(entity: string, filter?: Record): Promise { + const permissionOperation = this.resolvePermissionOperation('deleteMany') + this.checkPermission(entity, permissionOperation) + + const listResult = await this.baseAdapter.list(entity, { filter }) + for (const item of listResult.data) { + this.checkRowLevelAccess(entity, permissionOperation, item as Record) + } + + try { + const result = await this.baseAdapter.deleteMany(entity, filter) + if (this.auditLog) { + this.logAudit(entity, 'deleteMany', true) + } + return result + } catch (error) { + if (this.auditLog) { + this.logAudit(entity, 'deleteMany', false, error instanceof Error ? error.message : 'Unknown error') + } + throw error + } + } + async getCapabilities(): Promise { return this.baseAdapter.getCapabilities() } diff --git a/dbal/ts/src/adapters/adapter.ts b/dbal/ts/src/adapters/adapter.ts index 3ed0ad651..4844ea530 100644 --- a/dbal/ts/src/adapters/adapter.ts +++ b/dbal/ts/src/adapters/adapter.ts @@ -28,6 +28,7 @@ export interface DBALAdapter { deleteByField(entity: string, field: string, value: unknown): Promise deleteMany(entity: string, filter?: Record): Promise createMany(entity: string, data: Record[]): Promise + updateMany(entity: string, filter: Record, data: Record): Promise getCapabilities(): Promise close(): Promise diff --git a/dbal/ts/src/adapters/prisma-adapter.ts b/dbal/ts/src/adapters/prisma-adapter.ts index 93c50d634..26c78243f 100644 --- a/dbal/ts/src/adapters/prisma-adapter.ts +++ b/dbal/ts/src/adapters/prisma-adapter.ts @@ -196,6 +196,19 @@ export class PrismaAdapter implements DBALAdapter { } } + async updateMany(entity: string, filter: Record, data: Record): Promise { + try { + const model = this.getModel(entity) + const where = this.buildWhereClause(filter) + const result = await this.withTimeout( + model.updateMany({ where: where as never, data: data as never }) + ) + return result.count + } catch (error) { + throw this.handleError(error, 'updateMany', entity) + } + } + async createMany(entity: string, data: Record[]): Promise { try { const model = this.getModel(entity) diff --git a/dbal/ts/src/bridges/websocket-bridge.ts b/dbal/ts/src/bridges/websocket-bridge.ts index fbdbe4efe..f7aae85f8 100644 --- a/dbal/ts/src/bridges/websocket-bridge.ts +++ b/dbal/ts/src/bridges/websocket-bridge.ts @@ -129,6 +129,44 @@ export class WebSocketBridge implements DBALAdapter { return this.call('list', entity, options) as Promise> } + async findFirst(entity: string, filter?: Record): Promise { + return this.call('findFirst', entity, filter) as Promise + } + + async findByField(entity: string, field: string, value: unknown): Promise { + return this.call('findByField', entity, field, value) as Promise + } + + async upsert( + entity: string, + uniqueField: string, + uniqueValue: unknown, + createData: Record, + updateData: Record + ): Promise { + return this.call('upsert', entity, uniqueField, uniqueValue, createData, updateData) + } + + async updateByField(entity: string, field: string, value: unknown, data: Record): Promise { + return this.call('updateByField', entity, field, value, data) + } + + async deleteByField(entity: string, field: string, value: unknown): Promise { + return this.call('deleteByField', entity, field, value) as Promise + } + + async deleteMany(entity: string, filter?: Record): Promise { + return this.call('deleteMany', entity, filter) as Promise + } + + async createMany(entity: string, data: Record[]): Promise { + return this.call('createMany', entity, data) as Promise + } + + async updateMany(entity: string, filter: Record, data: Record): Promise { + return this.call('updateMany', entity, filter, data) as Promise + } + async getCapabilities(): Promise { return this.call('getCapabilities') as Promise } diff --git a/dbal/ts/src/core/client.ts b/dbal/ts/src/core/client.ts index 43ebc44d1..734040968 100644 --- a/dbal/ts/src/core/client.ts +++ b/dbal/ts/src/core/client.ts @@ -164,6 +164,66 @@ export class DBALClient { list: async (options?: ListOptions): Promise> => { return this.adapter.list('User', options) as Promise> }, + createMany: async (data: Array>): Promise => { + if (!data || data.length === 0) { + return 0 + } + + const validationErrors = data.flatMap((item, index) => + validateUserCreate(item).map(error => ({ field: `users[${index}]`, error })) + ) + if (validationErrors.length > 0) { + throw DBALError.validationError('Invalid user batch', validationErrors) + } + + try { + return this.adapter.createMany('User', data as Record[]) + } catch (error) { + if (error instanceof DBALError && error.code === 409) { + throw DBALError.conflict('Username or email already exists') + } + throw error + } + }, + updateMany: async (filter: Record, data: Partial): Promise => { + if (!filter || Object.keys(filter).length === 0) { + throw DBALError.validationError('Bulk update requires a filter', [ + { field: 'filter', error: 'Filter is required' }, + ]) + } + + if (!data || Object.keys(data).length === 0) { + throw DBALError.validationError('Bulk update requires data', [ + { field: 'data', error: 'Update data is required' }, + ]) + } + + const validationErrors = validateUserUpdate(data) + if (validationErrors.length > 0) { + throw DBALError.validationError( + 'Invalid user update data', + validationErrors.map(error => ({ field: 'user', error })) + ) + } + + try { + return this.adapter.updateMany('User', filter, data as Record) + } catch (error) { + if (error instanceof DBALError && error.code === 409) { + throw DBALError.conflict('Username or email already exists') + } + throw error + } + }, + deleteMany: async (filter: Record): Promise => { + if (!filter || Object.keys(filter).length === 0) { + throw DBALError.validationError('Bulk delete requires a filter', [ + { field: 'filter', error: 'Filter is required' }, + ]) + } + + return this.adapter.deleteMany('User', filter) + }, } } @@ -582,6 +642,66 @@ export class DBALClient { list: async (options?: ListOptions): Promise> => { return this.adapter.list('Package', options) as Promise> }, + createMany: async (data: Array>): Promise => { + if (!data || data.length === 0) { + return 0 + } + + const validationErrors = data.flatMap((item, index) => + validatePackageCreate(item).map(error => ({ field: `packages[${index}]`, error })) + ) + if (validationErrors.length > 0) { + throw DBALError.validationError('Invalid package batch', validationErrors) + } + + try { + return this.adapter.createMany('Package', data as Record[]) + } catch (error) { + if (error instanceof DBALError && error.code === 409) { + throw DBALError.conflict('Package name+version already exists') + } + throw error + } + }, + updateMany: async (filter: Record, data: Partial): Promise => { + if (!filter || Object.keys(filter).length === 0) { + throw DBALError.validationError('Bulk update requires a filter', [ + { field: 'filter', error: 'Filter is required' }, + ]) + } + + if (!data || Object.keys(data).length === 0) { + throw DBALError.validationError('Bulk update requires data', [ + { field: 'data', error: 'Update data is required' }, + ]) + } + + const validationErrors = validatePackageUpdate(data) + if (validationErrors.length > 0) { + throw DBALError.validationError( + 'Invalid package update data', + validationErrors.map(error => ({ field: 'package', error })) + ) + } + + try { + return this.adapter.updateMany('Package', filter, data as Record) + } catch (error) { + if (error instanceof DBALError && error.code === 409) { + throw DBALError.conflict('Package name+version already exists') + } + throw error + } + }, + deleteMany: async (filter: Record): Promise => { + if (!filter || Object.keys(filter).length === 0) { + throw DBALError.validationError('Bulk delete requires a filter', [ + { field: 'filter', error: 'Filter is required' }, + ]) + } + + return this.adapter.deleteMany('Package', filter) + }, } } diff --git a/dbal/ts/tests/core/client-batch.test.ts b/dbal/ts/tests/core/client-batch.test.ts new file mode 100644 index 000000000..c1a41c26b --- /dev/null +++ b/dbal/ts/tests/core/client-batch.test.ts @@ -0,0 +1,141 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { DBALClient } from '../../src/core/client' +import { DBALErrorCode } from '../../src/core/errors' + +const mockAdapter = vi.hoisted(() => ({ + create: vi.fn(), + read: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + list: vi.fn(), + findFirst: vi.fn(), + findByField: vi.fn(), + upsert: vi.fn(), + updateByField: vi.fn(), + deleteByField: vi.fn(), + deleteMany: vi.fn(), + createMany: vi.fn(), + updateMany: vi.fn(), + getCapabilities: vi.fn(), + close: vi.fn(), +})) + +vi.mock('../../src/adapters/prisma-adapter', () => ({ + PrismaAdapter: vi.fn(() => mockAdapter), +})) + +const baseConfig = { + mode: 'development' as const, + adapter: 'prisma' as const, + database: { url: 'file:memory' }, +} + +const userBatch = [ + { username: 'alpha', email: 'alpha@example.com', role: 'user' as const }, + { username: 'beta', email: 'beta@example.com', role: 'admin' as const }, +] + +const packageBatch = [ + { + name: 'forum', + version: '1.0.0', + author: 'MetaBuilder', + manifest: { entry: 'index.lua' }, + isInstalled: false, + }, + { + name: 'chat', + version: '2.1.0', + author: 'MetaBuilder', + manifest: { entry: 'chat.lua' }, + isInstalled: true, + }, +] + +beforeEach(() => { + Object.values(mockAdapter).forEach(value => { + if (typeof value === 'function' && 'mockReset' in value) { + value.mockReset() + } + }) +}) + +describe('DBALClient batch operations', () => { + it('creates users in bulk via adapter', async () => { + mockAdapter.createMany.mockResolvedValue(2) + + const client = new DBALClient(baseConfig) + const result = await client.users.createMany(userBatch) + + expect(mockAdapter.createMany).toHaveBeenCalledWith('User', userBatch) + expect(result).toBe(2) + }) + + it('rejects bulk user create with invalid data', async () => { + const client = new DBALClient(baseConfig) + + await expect(client.users.createMany([ + { username: '', email: 'bad', role: 'user' as const }, + ])).rejects.toMatchObject({ code: DBALErrorCode.VALIDATION_ERROR }) + + expect(mockAdapter.createMany).not.toHaveBeenCalled() + }) + + it('updates users in bulk with a filter', async () => { + mockAdapter.updateMany.mockResolvedValue(1) + + const client = new DBALClient(baseConfig) + const result = await client.users.updateMany({ role: 'user' }, { role: 'admin' }) + + expect(mockAdapter.updateMany).toHaveBeenCalledWith('User', { role: 'user' }, { role: 'admin' }) + expect(result).toBe(1) + }) + + it('rejects bulk user update without a filter', async () => { + const client = new DBALClient(baseConfig) + + await expect(client.users.updateMany({}, { role: 'admin' })).rejects.toMatchObject({ + code: DBALErrorCode.VALIDATION_ERROR, + }) + }) + + it('deletes users in bulk with a filter', async () => { + mockAdapter.deleteMany.mockResolvedValue(2) + + const client = new DBALClient(baseConfig) + const result = await client.users.deleteMany({ role: 'user' }) + + expect(mockAdapter.deleteMany).toHaveBeenCalledWith('User', { role: 'user' }) + expect(result).toBe(2) + }) + + it('creates packages in bulk via adapter', async () => { + mockAdapter.createMany.mockResolvedValue(2) + + const client = new DBALClient(baseConfig) + const result = await client.packages.createMany(packageBatch) + + expect(mockAdapter.createMany).toHaveBeenCalledWith('Package', packageBatch) + expect(result).toBe(2) + }) + + it('updates packages in bulk with a filter', async () => { + mockAdapter.updateMany.mockResolvedValue(3) + + const client = new DBALClient(baseConfig) + const result = await client.packages.updateMany({ isInstalled: false }, { isInstalled: true }) + + expect(mockAdapter.updateMany).toHaveBeenCalledWith('Package', { isInstalled: false }, { isInstalled: true }) + expect(result).toBe(3) + }) + + it('deletes packages in bulk with a filter', async () => { + mockAdapter.deleteMany.mockResolvedValue(1) + + const client = new DBALClient(baseConfig) + const result = await client.packages.deleteMany({ name: 'forum' }) + + expect(mockAdapter.deleteMany).toHaveBeenCalledWith('Package', { name: 'forum' }) + expect(result).toBe(1) + }) +}) diff --git a/dbal/ts/tests/core/client-workflows.test.ts b/dbal/ts/tests/core/client-workflows.test.ts index b13ed0551..b01ac46ea 100644 --- a/dbal/ts/tests/core/client-workflows.test.ts +++ b/dbal/ts/tests/core/client-workflows.test.ts @@ -15,6 +15,7 @@ const mockAdapter = vi.hoisted(() => ({ deleteByField: vi.fn(), deleteMany: vi.fn(), createMany: vi.fn(), + updateMany: vi.fn(), getCapabilities: vi.fn(), close: vi.fn(), })) diff --git a/docs/architecture/database.md b/docs/architecture/database.md index 94a335eb1..dd3667b4c 100644 --- a/docs/architecture/database.md +++ b/docs/architecture/database.md @@ -115,6 +115,17 @@ const result = await dbalQuery({ | **Validation** | Schema | Custom rules | | **Performance** | Good | Optimized | +### Primary Key Mapping + +DBAL adapters normalize primary key lookups for Prisma models that use non-`id` keys. The adapter maps the entity name to its primary key field so standard `read`/`update`/`delete` calls work correctly. + +Non-`id` primary keys in this repo: +- `Credential.username` +- `InstalledPackage.packageId` +- `PackageData.packageId` +- `PasswordResetToken.username` +- `SystemConfig.key` + ## Database Operations ### Create (Insert) diff --git a/docs/guides/api-development.md b/docs/guides/api-development.md index cd1d63862..96a1bd822 100644 --- a/docs/guides/api-development.md +++ b/docs/guides/api-development.md @@ -18,6 +18,8 @@ app/api/ │ └── route.ts └── auth/ ├── login/route.ts + ├── register/route.ts + ├── session/route.ts └── logout/route.ts ``` @@ -75,6 +77,70 @@ Content-Type: application/json DELETE /api/users/{id} ``` +## Auth API (Session-backed) + +Authentication uses a database-backed session with an httpOnly cookie (`mb_session`). +Sessions default to a 7 day TTL and are refreshed on `/api/auth/session`. + +### Login + +``` +POST /api/auth/login +Content-Type: application/json + +{ + "identifier": "alice@example.com", + "password": "s3cret" +} +``` + +Response: + +``` +{ + "user": { + "id": "user_123", + "username": "alice", + "email": "alice@example.com", + "role": "user", + "createdAt": 1715612345678 + } +} +``` + +### Register + +``` +POST /api/auth/register +Content-Type: application/json + +{ + "username": "newuser", + "email": "newuser@example.com", + "password": "s3cret" +} +``` + +### Session Check + +``` +GET /api/auth/session +``` + +Response: + +``` +{ + "user": null +} +``` + +### Logout + +``` +POST /api/auth/logout +``` + ## Creating an API Endpoint ### 1. Create Route File diff --git a/docs/troubleshooting/WORKFLOW_FAILURE_DIAGNOSIS.md b/docs/troubleshooting/WORKFLOW_FAILURE_DIAGNOSIS.md index b1bdc5894..55a964af9 100644 --- a/docs/troubleshooting/WORKFLOW_FAILURE_DIAGNOSIS.md +++ b/docs/troubleshooting/WORKFLOW_FAILURE_DIAGNOSIS.md @@ -324,7 +324,8 @@ If you're inside the app, the GitHub Actions Monitor (Level 1) can fetch run log download them locally, and surface common error signals. For private repos or higher rate limits, set `GITHUB_TOKEN`. To target a different repository, set `GITHUB_OWNER` and `GITHUB_REPO` (used by the `/api/github/actions/runs` and -`/api/github/actions/runs/{runId}/logs` endpoints). +`/api/github/actions/runs/{runId}/logs` endpoints). The logs endpoint also accepts +`jobLimit` and `includeLogs` query parameters. --- diff --git a/frontends/nextjs/package-lock.json b/frontends/nextjs/package-lock.json index 4cf441cf4..b5889f75c 100644 --- a/frontends/nextjs/package-lock.json +++ b/frontends/nextjs/package-lock.json @@ -12,26 +12,26 @@ "@aws-sdk/client-s3": "^3.958.0", "@aws-sdk/lib-storage": "^3.958.0", "@aws-sdk/s3-request-presigner": "^3.958.0", - "@emotion/react": "^11.13.5", - "@emotion/styled": "^11.13.5", + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", "@github/spark": ">=0.43.1 <1", - "@hookform/resolvers": "^4.1.3", + "@hookform/resolvers": "^5.2.2", "@monaco-editor/react": "^4.7.0", - "@mui/icons-material": "^6.3.1", - "@mui/material": "^6.3.1", - "@mui/x-data-grid": "^7.24.2", - "@mui/x-date-pickers": "^7.24.1", + "@mui/icons-material": "^7.3.6", + "@mui/material": "^7.3.6", + "@mui/x-data-grid": "^8.23.0", + "@mui/x-date-pickers": "^8.23.0", "@next/third-parties": "^16.1.1", - "@octokit/core": "^6.1.4", + "@octokit/core": "^7.0.6", "@phosphor-icons/react": "^2.1.10", "@prisma/client": "^6.19.1", - "@tanstack/react-query": "^5.83.1", - "@types/jszip": "^3.4.0", + "@tanstack/react-query": "^5.90.12", + "@types/jszip": "^3.4.1", "d3": "^7.9.0", - "date-fns": "^3.6.0", + "date-fns": "^4.1.0", "fengari-interop": "^0.1.4", "fengari-web": "^0.1.4", - "framer-motion": "^12.6.2", + "framer-motion": "^12.23.26", "jszip": "^3.10.1", "marked": "^17.0.1", "next": "16.1.1", @@ -40,11 +40,11 @@ "react-dom": "19.2.3", "react-error-boundary": "^6.0.0", "react-hook-form": "^7.69.0", - "recharts": "^2.15.1", + "recharts": "^3.6.0", "server-only": "^0.0.1", "sharp": "^0.34.5", "sonner": "^2.0.7", - "three": "^0.175.0", + "three": "^0.182.0", "uuid": "^13.0.0", "zod": "^4.2.1" }, @@ -54,15 +54,15 @@ "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.1", "@types/node": "^25.0.3", - "@types/react": "^19.0.10", - "@types/react-dom": "^19.0.4", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", "@vitejs/plugin-react-swc": "^4.2.2", "@vitest/coverage-v8": "^4.0.16", "dotenv": "^17.2.3", - "eslint": "^9.28.0", + "eslint": "^9.39.2", "eslint-plugin-react": "^7.37.5", - "eslint-plugin-react-hooks": "^5.2.0", - "eslint-plugin-react-refresh": "^0.4.19", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.26", "globals": "^16.5.0", "jsdom": "^27.3.0", "prisma": "^6.19.1", @@ -1088,6 +1088,55 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/compat-data": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.27.1", + "@babel/generator": "^7.28.5", + "@babel/helper-compilation-targets": "^7.27.2", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", + "@babel/template": "^7.27.2", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/core/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/generator": { "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", @@ -1104,6 +1153,33 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.27.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.27.2.tgz", + "integrity": "sha512-2+1thGUUWWjLTYTHZWK1n8Yga0ijBz1XAhUXcKy81rd5g6yh7hGqMp45v7cadSbEHc9G3OTv45SyneRN3ps4DQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.27.2", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets/node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, "node_modules/@babel/helper-globals": { "version": "7.28.0", "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", @@ -1126,6 +1202,24 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.27.1", + "@babel/helper-validator-identifier": "^7.27.1", + "@babel/traverse": "^7.28.3" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", @@ -1144,6 +1238,30 @@ "node": ">=6.9.0" } }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.27.2", + "@babel/types": "^7.28.4" + }, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/parser": { "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", @@ -2107,15 +2225,15 @@ } }, "node_modules/@hookform/resolvers": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-4.1.3.tgz", - "integrity": "sha512-Jsv6UOWYTrEFJ/01ZrnwVXs7KDvP8XIo115i++5PWvNkNvkrsTfGiLS6w+eJ57CYtUtDQalUWovCZDHFJ8u1VQ==", + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz", + "integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==", "license": "MIT", "dependencies": { "@standard-schema/utils": "^0.3.0" }, "peerDependencies": { - "react-hook-form": "^7.0.0" + "react-hook-form": "^7.55.0" } }, "node_modules/@humanfs/core": { @@ -2645,6 +2763,17 @@ "@jridgewell/trace-mapping": "^0.3.24" } }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, "node_modules/@jridgewell/resolve-uri": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", @@ -2694,9 +2823,9 @@ } }, "node_modules/@mui/core-downloads-tracker": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-6.5.0.tgz", - "integrity": "sha512-LGb8t8i6M2ZtS3Drn3GbTI1DVhDY6FJ9crEey2lZ0aN2EMZo8IZBZj9wRf4vqbZHaWjsYgtbOnJw5V8UWbmK2Q==", + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.6.tgz", + "integrity": "sha512-QaYtTHlr8kDFN5mE1wbvVARRKH7Fdw1ZuOjBJcFdVpfNfRYKF3QLT4rt+WaB6CKJvpqxRsmEo0kpYinhH5GeHg==", "license": "MIT", "funding": { "type": "opencollective", @@ -2704,12 +2833,12 @@ } }, "node_modules/@mui/icons-material": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-6.5.0.tgz", - "integrity": "sha512-VPuPqXqbBPlcVSA0BmnoE4knW4/xG6Thazo8vCLWkOKusko6DtwFV6B665MMWJ9j0KFohTIf3yx2zYtYacvG1g==", + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-7.3.6.tgz", + "integrity": "sha512-0FfkXEj22ysIq5pa41A2NbcAhJSvmcZQ/vcTIbjDsd6hlslG82k5BEBqqS0ZJprxwIL3B45qpJ+bPHwJPlF7uQ==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.26.0" + "@babel/runtime": "^7.28.4" }, "engines": { "node": ">=14.0.0" @@ -2719,7 +2848,7 @@ "url": "https://opencollective.com/mui-org" }, "peerDependencies": { - "@mui/material": "^6.5.0", + "@mui/material": "^7.3.6", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, @@ -2730,23 +2859,23 @@ } }, "node_modules/@mui/material": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@mui/material/-/material-6.5.0.tgz", - "integrity": "sha512-yjvtXoFcrPLGtgKRxFaH6OQPtcLPhkloC0BML6rBG5UeldR0nPULR/2E2BfXdo5JNV7j7lOzrrLX2Qf/iSidow==", + "version": "7.3.6", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.6.tgz", + "integrity": "sha512-R4DaYF3dgCQCUAkr4wW1w26GHXcf5rCmBRHVBuuvJvaGLmZdD8EjatP80Nz5JCw0KxORAzwftnHzXVnjR8HnFw==", "license": "MIT", "peer": true, "dependencies": { - "@babel/runtime": "^7.26.0", - "@mui/core-downloads-tracker": "^6.5.0", - "@mui/system": "^6.5.0", - "@mui/types": "~7.2.24", - "@mui/utils": "^6.4.9", + "@babel/runtime": "^7.28.4", + "@mui/core-downloads-tracker": "^7.3.6", + "@mui/system": "^7.3.6", + "@mui/types": "^7.4.9", + "@mui/utils": "^7.3.6", "@popperjs/core": "^2.11.8", "@types/react-transition-group": "^4.4.12", "clsx": "^2.1.1", "csstype": "^3.1.3", "prop-types": "^15.8.1", - "react-is": "^19.0.0", + "react-is": "^19.2.0", "react-transition-group": "^4.4.5" }, "engines": { @@ -2759,7 +2888,7 @@ "peerDependencies": { "@emotion/react": "^11.5.0", "@emotion/styled": "^11.3.0", - "@mui/material-pigment-css": "^6.5.0", + "@mui/material-pigment-css": "^7.3.6", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" @@ -2779,107 +2908,6 @@ } } }, - "node_modules/@mui/material/node_modules/@mui/private-theming": { - "version": "6.4.9", - "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-6.4.9.tgz", - "integrity": "sha512-LktcVmI5X17/Q5SkwjCcdOLBzt1hXuc14jYa7NPShog0GBDCDvKtcnP0V7a2s6EiVRlv7BzbWEJzH6+l/zaCxw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.26.0", - "@mui/utils": "^6.4.9", - "prop-types": "^15.8.1" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@mui/material/node_modules/@mui/styled-engine": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-6.5.0.tgz", - "integrity": "sha512-8woC2zAqF4qUDSPIBZ8v3sakj+WgweolpyM/FXf8jAx6FMls+IE4Y8VDZc+zS805J7PRz31vz73n2SovKGaYgw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.26.0", - "@emotion/cache": "^11.13.5", - "@emotion/serialize": "^1.3.3", - "@emotion/sheet": "^1.4.0", - "csstype": "^3.1.3", - "prop-types": "^15.8.1" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@emotion/react": "^11.4.1", - "@emotion/styled": "^11.3.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@emotion/react": { - "optional": true - }, - "@emotion/styled": { - "optional": true - } - } - }, - "node_modules/@mui/material/node_modules/@mui/system": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/@mui/system/-/system-6.5.0.tgz", - "integrity": "sha512-XcbBYxDS+h/lgsoGe78ExXFZXtuIlSBpn/KsZq8PtZcIkUNJInkuDqcLd2rVBQrDC1u+rvVovdaWPf2FHKJf3w==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.26.0", - "@mui/private-theming": "^6.4.9", - "@mui/styled-engine": "^6.5.0", - "@mui/types": "~7.2.24", - "@mui/utils": "^6.4.9", - "clsx": "^2.1.1", - "csstype": "^3.1.3", - "prop-types": "^15.8.1" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@emotion/react": "^11.5.0", - "@emotion/styled": "^11.3.0", - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@emotion/react": { - "optional": true - }, - "@emotion/styled": { - "optional": true - }, - "@types/react": { - "optional": true - } - } - }, "node_modules/@mui/private-theming": { "version": "7.3.6", "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.3.6.tgz", @@ -2907,53 +2935,6 @@ } } }, - "node_modules/@mui/private-theming/node_modules/@mui/types": { - "version": "7.4.9", - "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.9.tgz", - "integrity": "sha512-dNO8Z9T2cujkSIaCnWwprfeKmTWh97cnjkgmpFJ2sbfXLx8SMZijCYHOtP/y5nnUb/Rm2omxbDMmtUoSaUtKaw==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4" - }, - "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@mui/private-theming/node_modules/@mui/utils": { - "version": "7.3.6", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.6.tgz", - "integrity": "sha512-jn+Ba02O6PiFs7nKva8R2aJJ9kJC+3kQ2R0BbKNY3KQQ36Qng98GnPRFTlbwYTdMD6hLEBKaMLUktyg/rTfd2w==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.28.4", - "@mui/types": "^7.4.9", - "@types/prop-types": "^15.7.15", - "clsx": "^2.1.1", - "prop-types": "^15.8.1", - "react-is": "^19.2.0" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@mui/styled-engine": { "version": "7.3.6", "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.3.6.tgz", @@ -3029,7 +3010,7 @@ } } }, - "node_modules/@mui/system/node_modules/@mui/types": { + "node_modules/@mui/types": { "version": "7.4.9", "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.9.tgz", "integrity": "sha512-dNO8Z9T2cujkSIaCnWwprfeKmTWh97cnjkgmpFJ2sbfXLx8SMZijCYHOtP/y5nnUb/Rm2omxbDMmtUoSaUtKaw==", @@ -3046,7 +3027,7 @@ } } }, - "node_modules/@mui/system/node_modules/@mui/utils": { + "node_modules/@mui/utils": { "version": "7.3.6", "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.6.tgz", "integrity": "sha512-jn+Ba02O6PiFs7nKva8R2aJJ9kJC+3kQ2R0BbKNY3KQQ36Qng98GnPRFTlbwYTdMD6hLEBKaMLUktyg/rTfd2w==", @@ -3076,63 +3057,19 @@ } } }, - "node_modules/@mui/types": { - "version": "7.2.24", - "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.2.24.tgz", - "integrity": "sha512-3c8tRt/CbWZ+pEg7QpSwbdxOk36EfmhbKf6AGZsD1EcLDLTSZoxxJ86FVtcjxvjuhdyBiWKSTGZFaXCnidO2kw==", - "license": "MIT", - "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, - "node_modules/@mui/utils": { - "version": "6.4.9", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-6.4.9.tgz", - "integrity": "sha512-Y12Q9hbK9g+ZY0T3Rxrx9m2m10gaphDuUMgWxyV5kNJevVxXYCLclYUCC9vXaIk1/NdNDTcW2Yfr2OGvNFNmHg==", - "license": "MIT", - "dependencies": { - "@babel/runtime": "^7.26.0", - "@mui/types": "~7.2.24", - "@types/prop-types": "^15.7.14", - "clsx": "^2.1.1", - "prop-types": "^15.8.1", - "react-is": "^19.0.0" - }, - "engines": { - "node": ">=14.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/mui-org" - }, - "peerDependencies": { - "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0" - }, - "peerDependenciesMeta": { - "@types/react": { - "optional": true - } - } - }, "node_modules/@mui/x-data-grid": { - "version": "7.29.12", - "resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-7.29.12.tgz", - "integrity": "sha512-MaEC7ubr/je8jVWjdRU7LxBXAzlOZwFEdNdvlDUJIYkRa3TRCQ1HsY8Gd8Od0jnlnMYn9M4BrEfOrq9VRnt4bw==", + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-8.23.0.tgz", + "integrity": "sha512-fv8AiibTgwPKJsXtYdc5bYDDW3EQp/ieQ0iUejvZ2JbYikMIPTYjI4pHy7zW3F3RDsi6DGQnw5AW6LeoDcT0fA==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.25.7", - "@mui/utils": "^5.16.6 || ^6.0.0 || ^7.0.0", - "@mui/x-internals": "7.29.0", + "@babel/runtime": "^7.28.4", + "@mui/utils": "^7.3.5", + "@mui/x-internals": "8.23.0", + "@mui/x-virtualizer": "0.3.0", "clsx": "^2.1.1", "prop-types": "^15.8.1", - "reselect": "^5.1.1", - "use-sync-external-store": "^1.0.0" + "use-sync-external-store": "^1.6.0" }, "engines": { "node": ">=14.0.0" @@ -3159,15 +3096,15 @@ } }, "node_modules/@mui/x-date-pickers": { - "version": "7.29.4", - "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-7.29.4.tgz", - "integrity": "sha512-wJ3tsqk/y6dp+mXGtT9czciAMEO5Zr3IIAHg9x6IL0Eqanqy0N3chbmQQZv3iq0m2qUpQDLvZ4utZBUTJdjNzw==", + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/@mui/x-date-pickers/-/x-date-pickers-8.23.0.tgz", + "integrity": "sha512-uKtam5wqMEuErmRxZLPEX/7CZZFTMfrl05V9cWNjBkpGTcdDBIs1Kba8z2pfQU93e9lSLrRlxbCMJzCu6iF0Rg==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.25.7", - "@mui/utils": "^5.16.6 || ^6.0.0 || ^7.0.0", - "@mui/x-internals": "7.29.0", - "@types/react-transition-group": "^4.4.11", + "@babel/runtime": "^7.28.4", + "@mui/utils": "^7.3.5", + "@mui/x-internals": "8.23.0", + "@types/react-transition-group": "^4.4.12", "clsx": "^2.1.1", "prop-types": "^15.8.1", "react-transition-group": "^4.4.5" @@ -3225,13 +3162,15 @@ } }, "node_modules/@mui/x-internals": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-7.29.0.tgz", - "integrity": "sha512-+Gk6VTZIFD70XreWvdXBwKd8GZ2FlSCuecQFzm6znwqXg1ZsndavrhG9tkxpxo2fM1Zf7Tk8+HcOO0hCbhTQFA==", + "version": "8.23.0", + "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-8.23.0.tgz", + "integrity": "sha512-FN7wdqwTxqq1tJBYVz8TA/HMcViuaHS0Jphr4pEjT/8Iuf94Yt3P82WbsTbXyYrgOQDQl07UqE7qWcJetRcHcg==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.25.7", - "@mui/utils": "^5.16.6 || ^6.0.0 || ^7.0.0" + "@babel/runtime": "^7.28.4", + "@mui/utils": "^7.3.5", + "reselect": "^5.1.1", + "use-sync-external-store": "^1.6.0" }, "engines": { "node": ">=14.0.0" @@ -3244,6 +3183,28 @@ "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/@mui/x-virtualizer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@mui/x-virtualizer/-/x-virtualizer-0.3.0.tgz", + "integrity": "sha512-ScQ3xullKQAtbIT9N7PVhgW9BDTo2Hu8fr/+N08TdRMJcq8bsVJB25mlcHk6zxjlgCuojqaeGfw71S1FOvyCXQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "@mui/utils": "^7.3.5", + "@mui/x-internals": "8.23.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@next/env": { "version": "16.1.1", "resolved": "https://registry.npmjs.org/@next/env/-/env-16.1.1.tgz", @@ -3409,125 +3370,6 @@ "node": ">= 20" } }, - "node_modules/@octokit/app/node_modules/@octokit/auth-token": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", - "integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==", - "license": "MIT", - "engines": { - "node": ">= 20" - } - }, - "node_modules/@octokit/app/node_modules/@octokit/core": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz", - "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", - "license": "MIT", - "dependencies": { - "@octokit/auth-token": "^6.0.0", - "@octokit/graphql": "^9.0.3", - "@octokit/request": "^10.0.6", - "@octokit/request-error": "^7.0.2", - "@octokit/types": "^16.0.0", - "before-after-hook": "^4.0.0", - "universal-user-agent": "^7.0.0" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/@octokit/app/node_modules/@octokit/endpoint": { - "version": "11.0.2", - "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.2.tgz", - "integrity": "sha512-4zCpzP1fWc7QlqunZ5bSEjxc6yLAlRTnDwKtgXfcI/FxxGoqedDG8V2+xJ60bV2kODqcGB+nATdtap/XYq2NZQ==", - "license": "MIT", - "dependencies": { - "@octokit/types": "^16.0.0", - "universal-user-agent": "^7.0.2" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/@octokit/app/node_modules/@octokit/graphql": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.3.tgz", - "integrity": "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==", - "license": "MIT", - "dependencies": { - "@octokit/request": "^10.0.6", - "@octokit/types": "^16.0.0", - "universal-user-agent": "^7.0.0" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/@octokit/app/node_modules/@octokit/openapi-types": { - "version": "27.0.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", - "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", - "license": "MIT" - }, - "node_modules/@octokit/app/node_modules/@octokit/request": { - "version": "10.0.7", - "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.7.tgz", - "integrity": "sha512-v93h0i1yu4idj8qFPZwjehoJx4j3Ntn+JhXsdJrG9pYaX6j/XRz2RmasMUHtNgQD39nrv/VwTWSqK0RNXR8upA==", - "license": "MIT", - "dependencies": { - "@octokit/endpoint": "^11.0.2", - "@octokit/request-error": "^7.0.2", - "@octokit/types": "^16.0.0", - "fast-content-type-parse": "^3.0.0", - "universal-user-agent": "^7.0.2" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/@octokit/app/node_modules/@octokit/request-error": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.0.tgz", - "integrity": "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==", - "license": "MIT", - "dependencies": { - "@octokit/types": "^16.0.0" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/@octokit/app/node_modules/@octokit/types": { - "version": "16.0.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", - "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", - "license": "MIT", - "dependencies": { - "@octokit/openapi-types": "^27.0.0" - } - }, - "node_modules/@octokit/app/node_modules/before-after-hook": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", - "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==", - "license": "Apache-2.0" - }, - "node_modules/@octokit/app/node_modules/fast-content-type-parse": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", - "integrity": "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, "node_modules/@octokit/auth-app": { "version": "8.1.2", "resolved": "https://registry.npmjs.org/@octokit/auth-app/-/auth-app-8.1.2.tgz", @@ -3547,78 +3389,6 @@ "node": ">= 20" } }, - "node_modules/@octokit/auth-app/node_modules/@octokit/endpoint": { - "version": "11.0.2", - "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.2.tgz", - "integrity": "sha512-4zCpzP1fWc7QlqunZ5bSEjxc6yLAlRTnDwKtgXfcI/FxxGoqedDG8V2+xJ60bV2kODqcGB+nATdtap/XYq2NZQ==", - "license": "MIT", - "dependencies": { - "@octokit/types": "^16.0.0", - "universal-user-agent": "^7.0.2" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/@octokit/auth-app/node_modules/@octokit/openapi-types": { - "version": "27.0.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", - "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", - "license": "MIT" - }, - "node_modules/@octokit/auth-app/node_modules/@octokit/request": { - "version": "10.0.7", - "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.7.tgz", - "integrity": "sha512-v93h0i1yu4idj8qFPZwjehoJx4j3Ntn+JhXsdJrG9pYaX6j/XRz2RmasMUHtNgQD39nrv/VwTWSqK0RNXR8upA==", - "license": "MIT", - "dependencies": { - "@octokit/endpoint": "^11.0.2", - "@octokit/request-error": "^7.0.2", - "@octokit/types": "^16.0.0", - "fast-content-type-parse": "^3.0.0", - "universal-user-agent": "^7.0.2" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/@octokit/auth-app/node_modules/@octokit/request-error": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.0.tgz", - "integrity": "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==", - "license": "MIT", - "dependencies": { - "@octokit/types": "^16.0.0" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/@octokit/auth-app/node_modules/@octokit/types": { - "version": "16.0.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", - "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", - "license": "MIT", - "dependencies": { - "@octokit/openapi-types": "^27.0.0" - } - }, - "node_modules/@octokit/auth-app/node_modules/fast-content-type-parse": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", - "integrity": "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, "node_modules/@octokit/auth-oauth-app": { "version": "9.0.3", "resolved": "https://registry.npmjs.org/@octokit/auth-oauth-app/-/auth-oauth-app-9.0.3.tgz", @@ -3635,78 +3405,6 @@ "node": ">= 20" } }, - "node_modules/@octokit/auth-oauth-app/node_modules/@octokit/endpoint": { - "version": "11.0.2", - "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.2.tgz", - "integrity": "sha512-4zCpzP1fWc7QlqunZ5bSEjxc6yLAlRTnDwKtgXfcI/FxxGoqedDG8V2+xJ60bV2kODqcGB+nATdtap/XYq2NZQ==", - "license": "MIT", - "dependencies": { - "@octokit/types": "^16.0.0", - "universal-user-agent": "^7.0.2" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/@octokit/auth-oauth-app/node_modules/@octokit/openapi-types": { - "version": "27.0.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", - "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", - "license": "MIT" - }, - "node_modules/@octokit/auth-oauth-app/node_modules/@octokit/request": { - "version": "10.0.7", - "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.7.tgz", - "integrity": "sha512-v93h0i1yu4idj8qFPZwjehoJx4j3Ntn+JhXsdJrG9pYaX6j/XRz2RmasMUHtNgQD39nrv/VwTWSqK0RNXR8upA==", - "license": "MIT", - "dependencies": { - "@octokit/endpoint": "^11.0.2", - "@octokit/request-error": "^7.0.2", - "@octokit/types": "^16.0.0", - "fast-content-type-parse": "^3.0.0", - "universal-user-agent": "^7.0.2" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/@octokit/auth-oauth-app/node_modules/@octokit/request-error": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.0.tgz", - "integrity": "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==", - "license": "MIT", - "dependencies": { - "@octokit/types": "^16.0.0" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/@octokit/auth-oauth-app/node_modules/@octokit/types": { - "version": "16.0.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", - "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", - "license": "MIT", - "dependencies": { - "@octokit/openapi-types": "^27.0.0" - } - }, - "node_modules/@octokit/auth-oauth-app/node_modules/fast-content-type-parse": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", - "integrity": "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, "node_modules/@octokit/auth-oauth-device": { "version": "8.0.3", "resolved": "https://registry.npmjs.org/@octokit/auth-oauth-device/-/auth-oauth-device-8.0.3.tgz", @@ -3722,78 +3420,6 @@ "node": ">= 20" } }, - "node_modules/@octokit/auth-oauth-device/node_modules/@octokit/endpoint": { - "version": "11.0.2", - "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.2.tgz", - "integrity": "sha512-4zCpzP1fWc7QlqunZ5bSEjxc6yLAlRTnDwKtgXfcI/FxxGoqedDG8V2+xJ60bV2kODqcGB+nATdtap/XYq2NZQ==", - "license": "MIT", - "dependencies": { - "@octokit/types": "^16.0.0", - "universal-user-agent": "^7.0.2" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/@octokit/auth-oauth-device/node_modules/@octokit/openapi-types": { - "version": "27.0.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", - "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", - "license": "MIT" - }, - "node_modules/@octokit/auth-oauth-device/node_modules/@octokit/request": { - "version": "10.0.7", - "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.7.tgz", - "integrity": "sha512-v93h0i1yu4idj8qFPZwjehoJx4j3Ntn+JhXsdJrG9pYaX6j/XRz2RmasMUHtNgQD39nrv/VwTWSqK0RNXR8upA==", - "license": "MIT", - "dependencies": { - "@octokit/endpoint": "^11.0.2", - "@octokit/request-error": "^7.0.2", - "@octokit/types": "^16.0.0", - "fast-content-type-parse": "^3.0.0", - "universal-user-agent": "^7.0.2" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/@octokit/auth-oauth-device/node_modules/@octokit/request-error": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.0.tgz", - "integrity": "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==", - "license": "MIT", - "dependencies": { - "@octokit/types": "^16.0.0" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/@octokit/auth-oauth-device/node_modules/@octokit/types": { - "version": "16.0.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", - "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", - "license": "MIT", - "dependencies": { - "@octokit/openapi-types": "^27.0.0" - } - }, - "node_modules/@octokit/auth-oauth-device/node_modules/fast-content-type-parse": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", - "integrity": "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, "node_modules/@octokit/auth-oauth-user": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/@octokit/auth-oauth-user/-/auth-oauth-user-6.0.2.tgz", @@ -3810,85 +3436,13 @@ "node": ">= 20" } }, - "node_modules/@octokit/auth-oauth-user/node_modules/@octokit/endpoint": { - "version": "11.0.2", - "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.2.tgz", - "integrity": "sha512-4zCpzP1fWc7QlqunZ5bSEjxc6yLAlRTnDwKtgXfcI/FxxGoqedDG8V2+xJ60bV2kODqcGB+nATdtap/XYq2NZQ==", - "license": "MIT", - "dependencies": { - "@octokit/types": "^16.0.0", - "universal-user-agent": "^7.0.2" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/@octokit/auth-oauth-user/node_modules/@octokit/openapi-types": { - "version": "27.0.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", - "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", - "license": "MIT" - }, - "node_modules/@octokit/auth-oauth-user/node_modules/@octokit/request": { - "version": "10.0.7", - "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.7.tgz", - "integrity": "sha512-v93h0i1yu4idj8qFPZwjehoJx4j3Ntn+JhXsdJrG9pYaX6j/XRz2RmasMUHtNgQD39nrv/VwTWSqK0RNXR8upA==", - "license": "MIT", - "dependencies": { - "@octokit/endpoint": "^11.0.2", - "@octokit/request-error": "^7.0.2", - "@octokit/types": "^16.0.0", - "fast-content-type-parse": "^3.0.0", - "universal-user-agent": "^7.0.2" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/@octokit/auth-oauth-user/node_modules/@octokit/request-error": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.0.tgz", - "integrity": "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==", - "license": "MIT", - "dependencies": { - "@octokit/types": "^16.0.0" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/@octokit/auth-oauth-user/node_modules/@octokit/types": { - "version": "16.0.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", - "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", - "license": "MIT", - "dependencies": { - "@octokit/openapi-types": "^27.0.0" - } - }, - "node_modules/@octokit/auth-oauth-user/node_modules/fast-content-type-parse": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", - "integrity": "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, "node_modules/@octokit/auth-token": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-5.1.2.tgz", - "integrity": "sha512-JcQDsBdg49Yky2w2ld20IHAlwr8d/d8N6NiOXbtuoPCqzbsiJgF633mVUw3x4mo0H5ypataQIX7SFu3yy44Mpw==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", + "integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==", "license": "MIT", "engines": { - "node": ">= 18" + "node": ">= 20" } }, "node_modules/@octokit/auth-unauthenticated": { @@ -3904,77 +3458,50 @@ "node": ">= 20" } }, - "node_modules/@octokit/auth-unauthenticated/node_modules/@octokit/openapi-types": { - "version": "27.0.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", - "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", - "license": "MIT" - }, - "node_modules/@octokit/auth-unauthenticated/node_modules/@octokit/request-error": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.0.tgz", - "integrity": "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==", + "node_modules/@octokit/core": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz", + "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", "license": "MIT", + "peer": true, "dependencies": { - "@octokit/types": "^16.0.0" + "@octokit/auth-token": "^6.0.0", + "@octokit/graphql": "^9.0.3", + "@octokit/request": "^10.0.6", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "before-after-hook": "^4.0.0", + "universal-user-agent": "^7.0.0" }, "engines": { "node": ">= 20" } }, - "node_modules/@octokit/auth-unauthenticated/node_modules/@octokit/types": { - "version": "16.0.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", - "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", - "license": "MIT", - "dependencies": { - "@octokit/openapi-types": "^27.0.0" - } - }, - "node_modules/@octokit/core": { - "version": "6.1.6", - "resolved": "https://registry.npmjs.org/@octokit/core/-/core-6.1.6.tgz", - "integrity": "sha512-kIU8SLQkYWGp3pVKiYzA5OSaNF5EE03P/R8zEmmrG6XwOg5oBjXyQVVIauQ0dgau4zYhpZEhJrvIYt6oM+zZZA==", - "license": "MIT", - "peer": true, - "dependencies": { - "@octokit/auth-token": "^5.0.0", - "@octokit/graphql": "^8.2.2", - "@octokit/request": "^9.2.3", - "@octokit/request-error": "^6.1.8", - "@octokit/types": "^14.0.0", - "before-after-hook": "^3.0.2", - "universal-user-agent": "^7.0.0" - }, - "engines": { - "node": ">= 18" - } - }, "node_modules/@octokit/endpoint": { - "version": "10.1.4", - "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-10.1.4.tgz", - "integrity": "sha512-OlYOlZIsfEVZm5HCSR8aSg02T2lbUWOsCQoPKfTXJwDzcHQBrVBGdGXb89dv2Kw2ToZaRtudp8O3ZIYoaOjKlA==", + "version": "11.0.2", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.2.tgz", + "integrity": "sha512-4zCpzP1fWc7QlqunZ5bSEjxc6yLAlRTnDwKtgXfcI/FxxGoqedDG8V2+xJ60bV2kODqcGB+nATdtap/XYq2NZQ==", "license": "MIT", "dependencies": { - "@octokit/types": "^14.0.0", + "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" }, "engines": { - "node": ">= 18" + "node": ">= 20" } }, "node_modules/@octokit/graphql": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-8.2.2.tgz", - "integrity": "sha512-Yi8hcoqsrXGdt0yObxbebHXFOiUA+2v3n53epuOg1QUgOB6c4XzvisBNVXJSl8RYA5KrDuSL2yq9Qmqe5N0ryA==", + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.3.tgz", + "integrity": "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==", "license": "MIT", "dependencies": { - "@octokit/request": "^9.2.3", - "@octokit/types": "^14.0.0", + "@octokit/request": "^10.0.6", + "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.0" }, "engines": { - "node": ">= 18" + "node": ">= 20" } }, "node_modules/@octokit/oauth-app": { @@ -3996,125 +3523,6 @@ "node": ">= 20" } }, - "node_modules/@octokit/oauth-app/node_modules/@octokit/auth-token": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", - "integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==", - "license": "MIT", - "engines": { - "node": ">= 20" - } - }, - "node_modules/@octokit/oauth-app/node_modules/@octokit/core": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz", - "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", - "license": "MIT", - "dependencies": { - "@octokit/auth-token": "^6.0.0", - "@octokit/graphql": "^9.0.3", - "@octokit/request": "^10.0.6", - "@octokit/request-error": "^7.0.2", - "@octokit/types": "^16.0.0", - "before-after-hook": "^4.0.0", - "universal-user-agent": "^7.0.0" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/@octokit/oauth-app/node_modules/@octokit/endpoint": { - "version": "11.0.2", - "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.2.tgz", - "integrity": "sha512-4zCpzP1fWc7QlqunZ5bSEjxc6yLAlRTnDwKtgXfcI/FxxGoqedDG8V2+xJ60bV2kODqcGB+nATdtap/XYq2NZQ==", - "license": "MIT", - "dependencies": { - "@octokit/types": "^16.0.0", - "universal-user-agent": "^7.0.2" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/@octokit/oauth-app/node_modules/@octokit/graphql": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.3.tgz", - "integrity": "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==", - "license": "MIT", - "dependencies": { - "@octokit/request": "^10.0.6", - "@octokit/types": "^16.0.0", - "universal-user-agent": "^7.0.0" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/@octokit/oauth-app/node_modules/@octokit/openapi-types": { - "version": "27.0.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", - "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", - "license": "MIT" - }, - "node_modules/@octokit/oauth-app/node_modules/@octokit/request": { - "version": "10.0.7", - "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.7.tgz", - "integrity": "sha512-v93h0i1yu4idj8qFPZwjehoJx4j3Ntn+JhXsdJrG9pYaX6j/XRz2RmasMUHtNgQD39nrv/VwTWSqK0RNXR8upA==", - "license": "MIT", - "dependencies": { - "@octokit/endpoint": "^11.0.2", - "@octokit/request-error": "^7.0.2", - "@octokit/types": "^16.0.0", - "fast-content-type-parse": "^3.0.0", - "universal-user-agent": "^7.0.2" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/@octokit/oauth-app/node_modules/@octokit/request-error": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.0.tgz", - "integrity": "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==", - "license": "MIT", - "dependencies": { - "@octokit/types": "^16.0.0" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/@octokit/oauth-app/node_modules/@octokit/types": { - "version": "16.0.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", - "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", - "license": "MIT", - "dependencies": { - "@octokit/openapi-types": "^27.0.0" - } - }, - "node_modules/@octokit/oauth-app/node_modules/before-after-hook": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", - "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==", - "license": "Apache-2.0" - }, - "node_modules/@octokit/oauth-app/node_modules/fast-content-type-parse": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", - "integrity": "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, "node_modules/@octokit/oauth-authorization-url": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/@octokit/oauth-authorization-url/-/oauth-authorization-url-8.0.0.tgz", @@ -4139,84 +3547,12 @@ "node": ">= 20" } }, - "node_modules/@octokit/oauth-methods/node_modules/@octokit/endpoint": { - "version": "11.0.2", - "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.2.tgz", - "integrity": "sha512-4zCpzP1fWc7QlqunZ5bSEjxc6yLAlRTnDwKtgXfcI/FxxGoqedDG8V2+xJ60bV2kODqcGB+nATdtap/XYq2NZQ==", - "license": "MIT", - "dependencies": { - "@octokit/types": "^16.0.0", - "universal-user-agent": "^7.0.2" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/@octokit/oauth-methods/node_modules/@octokit/openapi-types": { + "node_modules/@octokit/openapi-types": { "version": "27.0.0", "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", "license": "MIT" }, - "node_modules/@octokit/oauth-methods/node_modules/@octokit/request": { - "version": "10.0.7", - "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.7.tgz", - "integrity": "sha512-v93h0i1yu4idj8qFPZwjehoJx4j3Ntn+JhXsdJrG9pYaX6j/XRz2RmasMUHtNgQD39nrv/VwTWSqK0RNXR8upA==", - "license": "MIT", - "dependencies": { - "@octokit/endpoint": "^11.0.2", - "@octokit/request-error": "^7.0.2", - "@octokit/types": "^16.0.0", - "fast-content-type-parse": "^3.0.0", - "universal-user-agent": "^7.0.2" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/@octokit/oauth-methods/node_modules/@octokit/request-error": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.0.tgz", - "integrity": "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==", - "license": "MIT", - "dependencies": { - "@octokit/types": "^16.0.0" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/@octokit/oauth-methods/node_modules/@octokit/types": { - "version": "16.0.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", - "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", - "license": "MIT", - "dependencies": { - "@octokit/openapi-types": "^27.0.0" - } - }, - "node_modules/@octokit/oauth-methods/node_modules/fast-content-type-parse": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", - "integrity": "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, - "node_modules/@octokit/openapi-types": { - "version": "25.1.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-25.1.0.tgz", - "integrity": "sha512-idsIggNXUKkk0+BExUn1dQ92sfysJrje03Q0bv0e+KPLrvyqZF8MnBpFz8UNfYDwB3Ie7Z0TByjWfzxt7vseaA==", - "license": "MIT" - }, "node_modules/@octokit/openapi-webhooks-types": { "version": "12.1.0", "resolved": "https://registry.npmjs.org/@octokit/openapi-webhooks-types/-/openapi-webhooks-types-12.1.0.tgz", @@ -4250,21 +3586,6 @@ "@octokit/core": ">=6" } }, - "node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/openapi-types": { - "version": "27.0.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", - "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", - "license": "MIT" - }, - "node_modules/@octokit/plugin-paginate-rest/node_modules/@octokit/types": { - "version": "16.0.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", - "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", - "license": "MIT", - "dependencies": { - "@octokit/openapi-types": "^27.0.0" - } - }, "node_modules/@octokit/plugin-rest-endpoint-methods": { "version": "17.0.0", "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-17.0.0.tgz", @@ -4280,13 +3601,35 @@ "@octokit/core": ">=6" } }, - "node_modules/@octokit/plugin-rest-endpoint-methods/node_modules/@octokit/openapi-types": { - "version": "27.0.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", - "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", - "license": "MIT" + "node_modules/@octokit/request": { + "version": "10.0.7", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.7.tgz", + "integrity": "sha512-v93h0i1yu4idj8qFPZwjehoJx4j3Ntn+JhXsdJrG9pYaX6j/XRz2RmasMUHtNgQD39nrv/VwTWSqK0RNXR8upA==", + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^11.0.2", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "fast-content-type-parse": "^3.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } }, - "node_modules/@octokit/plugin-rest-endpoint-methods/node_modules/@octokit/types": { + "node_modules/@octokit/request-error": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.0.tgz", + "integrity": "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/types": { "version": "16.0.0", "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", @@ -4295,43 +3638,6 @@ "@octokit/openapi-types": "^27.0.0" } }, - "node_modules/@octokit/request": { - "version": "9.2.4", - "resolved": "https://registry.npmjs.org/@octokit/request/-/request-9.2.4.tgz", - "integrity": "sha512-q8ybdytBmxa6KogWlNa818r0k1wlqzNC+yNkcQDECHvQo8Vmstrg18JwqJHdJdUiHD2sjlwBgSm9kHkOKe2iyA==", - "license": "MIT", - "dependencies": { - "@octokit/endpoint": "^10.1.4", - "@octokit/request-error": "^6.1.8", - "@octokit/types": "^14.0.0", - "fast-content-type-parse": "^2.0.0", - "universal-user-agent": "^7.0.2" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/@octokit/request-error": { - "version": "6.1.8", - "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-6.1.8.tgz", - "integrity": "sha512-WEi/R0Jmq+IJKydWlKDmryPcmdYSVjL3ekaiEL1L9eo1sUnqMJ+grqmC9cjk7CA7+b2/T397tO5d8YLOH3qYpQ==", - "license": "MIT", - "dependencies": { - "@octokit/types": "^14.0.0" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/@octokit/types": { - "version": "14.1.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-14.1.0.tgz", - "integrity": "sha512-1y6DgTy8Jomcpu33N+p5w58l6xyt55Ar2I91RPiIA0xCJBXyUAhXCcmZaDWSANiha7R9a6qJJ2CRomGPZ6f46g==", - "license": "MIT", - "dependencies": { - "@octokit/openapi-types": "^25.1.0" - } - }, "node_modules/@octokit/webhooks": { "version": "14.2.0", "resolved": "https://registry.npmjs.org/@octokit/webhooks/-/webhooks-14.2.0.tgz", @@ -4355,33 +3661,6 @@ "node": ">= 20" } }, - "node_modules/@octokit/webhooks/node_modules/@octokit/openapi-types": { - "version": "27.0.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", - "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", - "license": "MIT" - }, - "node_modules/@octokit/webhooks/node_modules/@octokit/request-error": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.0.tgz", - "integrity": "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==", - "license": "MIT", - "dependencies": { - "@octokit/types": "^16.0.0" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/@octokit/webhooks/node_modules/@octokit/types": { - "version": "16.0.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", - "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", - "license": "MIT", - "dependencies": { - "@octokit/openapi-types": "^27.0.0" - } - }, "node_modules/@parcel/watcher": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.1.tgz", @@ -4845,6 +4124,42 @@ "@prisma/debug": "6.19.1" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.0.tgz", + "integrity": "sha512-dlzb07f5LDY+tzs+iLCSXV2yuhaYfezqyZQc+n6baLECWkOMEWxkECAOnXL0ba7lsA25fM9b2jtzpu/uxo1a7g==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.47", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.47.tgz", @@ -5874,7 +5189,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", - "devOptional": true, "license": "MIT" }, "node_modules/@standard-schema/utils": { @@ -6292,9 +5606,10 @@ "license": "MIT" }, "node_modules/@types/jszip": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/@types/jszip/-/jszip-3.4.0.tgz", - "integrity": "sha512-GFHqtQQP3R4NNuvZH3hNCYD0NbyBZ42bkN7kO3NDrU/SnvIZWMS8Bp38XCsRKBT5BXvgm0y1zqpZWp/ZkRzBzg==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@types/jszip/-/jszip-3.4.1.tgz", + "integrity": "sha512-TezXjmf3lj+zQ651r6hPqvSScqBLvyPI9FxdXBqpEwBijNGQ2NXpaFW/7joGzveYkKQUil7iiDHLo6LV71Pc0A==", + "deprecated": "This is a stub types definition. jszip provides its own type definitions, so you do not need this installed.", "license": "MIT", "dependencies": { "jszip": "*" @@ -6360,6 +5675,12 @@ "license": "MIT", "optional": true }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.50.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.50.1.tgz", @@ -7142,9 +6463,9 @@ } }, "node_modules/before-after-hook": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-3.0.2.tgz", - "integrity": "sha512-Nik3Sc0ncrMK4UUdXQmAnRtzmNQTAAXmXIopizwZ1W1t8QmfJj+zL4OA2I7XPTPW5z5TDqv4hRo/JzouDJnX3A==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", + "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==", "license": "Apache-2.0" }, "node_modules/bidi-js": { @@ -7232,6 +6553,41 @@ "node": ">=8" } }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, "node_modules/buffer": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.6.0.tgz", @@ -8059,9 +7415,9 @@ } }, "node_modules/date-fns": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-3.6.0.tgz", - "integrity": "sha512-fRHTG8g/Gif+kSh50gaGEdToemgfj74aRX3swtiouboip5JDLAyDE9F11nHMIcvOaXeOC6D7SpNhi7uFyB7Uww==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", "license": "MIT", "peer": true, "funding": { @@ -8300,6 +7656,13 @@ "fast-check": "^3.23.1" } }, + "node_modules/electron-to-chromium": { + "version": "1.5.267", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.267.tgz", + "integrity": "sha512-0Drusm6MVRXSOJpGbaSVgcQsuB4hEkMpHXaVstcPmhu5LIedxs1xNK/nIxmQIU/RPC0+1/o0AVZfBTkTNJOdUw==", + "dev": true, + "license": "ISC" + }, "node_modules/empathic": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/empathic/-/empathic-2.0.0.tgz", @@ -8522,6 +7885,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-toolkit": { + "version": "1.43.0", + "resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.43.0.tgz", + "integrity": "sha512-SKCT8AsWvYzBBuUqMk4NPwFlSdqLpJwmy6AP322ERn8W2YLIB6JBXnwMI2Qsh2gfphT3q7EKAxKb23cvFHFwKA==", + "license": "MIT", + "workspaces": [ + "docs", + "benchmarks" + ] + }, "node_modules/esbuild": { "version": "0.27.2", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.2.tgz", @@ -8563,6 +7936,16 @@ "@esbuild/win32-x64": "0.27.2" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -8676,13 +8059,20 @@ } }, "node_modules/eslint-plugin-react-hooks": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.2.0.tgz", - "integrity": "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", "dev": true, "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, "engines": { - "node": ">=10" + "node": ">=18" }, "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" @@ -8830,9 +8220,9 @@ } }, "node_modules/eventemitter3": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", - "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz", + "integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==", "license": "MIT" }, "node_modules/events": { @@ -9006,9 +8396,9 @@ } }, "node_modules/fast-content-type-parse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-2.0.1.tgz", - "integrity": "sha512-nGqtvLrj5w0naR6tDPfB4cUmYCqouzyQiz6C5y/LtcDllJdrcc6WaWW6iXyIIOErTa/XRybj28aasdn4LkVk6Q==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", + "integrity": "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==", "funding": [ { "type": "github", @@ -9028,15 +8418,6 @@ "dev": true, "license": "MIT" }, - "node_modules/fast-equals": { - "version": "5.4.0", - "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", - "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -9316,6 +8697,16 @@ "node": ">= 0.4" } }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -9536,6 +8927,23 @@ "node": ">= 0.4" } }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, "node_modules/hoist-non-react-statics": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz", @@ -9667,6 +9075,16 @@ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", "license": "MIT" }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/immutable": { "version": "5.1.4", "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz", @@ -10343,6 +9761,19 @@ "dev": true, "license": "MIT" }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/jsx-ast-utils": { "version": "3.3.5", "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", @@ -10426,12 +9857,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash": { - "version": "4.17.21", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "license": "MIT" - }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -10777,6 +10202,13 @@ "devOptional": true, "license": "MIT" }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, "node_modules/nypm": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/nypm/-/nypm-0.6.2.tgz", @@ -10936,67 +10368,6 @@ "node": ">= 20" } }, - "node_modules/octokit/node_modules/@octokit/auth-token": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", - "integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==", - "license": "MIT", - "engines": { - "node": ">= 20" - } - }, - "node_modules/octokit/node_modules/@octokit/core": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz", - "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", - "license": "MIT", - "peer": true, - "dependencies": { - "@octokit/auth-token": "^6.0.0", - "@octokit/graphql": "^9.0.3", - "@octokit/request": "^10.0.6", - "@octokit/request-error": "^7.0.2", - "@octokit/types": "^16.0.0", - "before-after-hook": "^4.0.0", - "universal-user-agent": "^7.0.0" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/octokit/node_modules/@octokit/endpoint": { - "version": "11.0.2", - "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.2.tgz", - "integrity": "sha512-4zCpzP1fWc7QlqunZ5bSEjxc6yLAlRTnDwKtgXfcI/FxxGoqedDG8V2+xJ60bV2kODqcGB+nATdtap/XYq2NZQ==", - "license": "MIT", - "dependencies": { - "@octokit/types": "^16.0.0", - "universal-user-agent": "^7.0.2" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/octokit/node_modules/@octokit/graphql": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.3.tgz", - "integrity": "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==", - "license": "MIT", - "dependencies": { - "@octokit/request": "^10.0.6", - "@octokit/types": "^16.0.0", - "universal-user-agent": "^7.0.0" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/octokit/node_modules/@octokit/openapi-types": { - "version": "27.0.0", - "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", - "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", - "license": "MIT" - }, "node_modules/octokit/node_modules/@octokit/plugin-retry": { "version": "8.0.3", "resolved": "https://registry.npmjs.org/@octokit/plugin-retry/-/plugin-retry-8.0.3.tgz", @@ -11030,65 +10401,6 @@ "@octokit/core": "^7.0.0" } }, - "node_modules/octokit/node_modules/@octokit/request": { - "version": "10.0.7", - "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.7.tgz", - "integrity": "sha512-v93h0i1yu4idj8qFPZwjehoJx4j3Ntn+JhXsdJrG9pYaX6j/XRz2RmasMUHtNgQD39nrv/VwTWSqK0RNXR8upA==", - "license": "MIT", - "dependencies": { - "@octokit/endpoint": "^11.0.2", - "@octokit/request-error": "^7.0.2", - "@octokit/types": "^16.0.0", - "fast-content-type-parse": "^3.0.0", - "universal-user-agent": "^7.0.2" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/octokit/node_modules/@octokit/request-error": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.0.tgz", - "integrity": "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==", - "license": "MIT", - "dependencies": { - "@octokit/types": "^16.0.0" - }, - "engines": { - "node": ">= 20" - } - }, - "node_modules/octokit/node_modules/@octokit/types": { - "version": "16.0.0", - "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", - "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", - "license": "MIT", - "dependencies": { - "@octokit/openapi-types": "^27.0.0" - } - }, - "node_modules/octokit/node_modules/before-after-hook": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", - "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==", - "license": "Apache-2.0" - }, - "node_modules/octokit/node_modules/fast-content-type-parse": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", - "integrity": "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "MIT" - }, "node_modules/ohash": { "version": "2.0.11", "resolved": "https://registry.npmjs.org/ohash/-/ohash-2.0.11.tgz", @@ -11653,21 +10965,31 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.3.tgz", "integrity": "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA==", - "license": "MIT" - }, - "node_modules/react-smooth": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", - "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", "license": "MIT", + "peer": true + }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "peer": true, "dependencies": { - "fast-equals": "^5.0.1", - "prop-types": "^15.8.1", - "react-transition-group": "^4.4.5" + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" }, "peerDependencies": { - "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } } }, "node_modules/react-transition-group": { @@ -11725,42 +11047,50 @@ } }, "node_modules/recharts": { - "version": "2.15.4", - "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", - "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-3.6.0.tgz", + "integrity": "sha512-L5bjxvQRAe26RlToBAziKUB7whaGKEwD3znoM6fz3DrTowCIC/FnJYnuq1GEzB8Zv2kdTfaxQfi5GoH0tBinyg==", "license": "MIT", + "workspaces": [ + "www" + ], "dependencies": { - "clsx": "^2.0.0", - "eventemitter3": "^4.0.1", - "lodash": "^4.17.21", - "react-is": "^18.3.1", - "react-smooth": "^4.0.4", - "recharts-scale": "^0.4.4", - "tiny-invariant": "^1.3.1", - "victory-vendor": "^36.6.8" + "@reduxjs/toolkit": "1.x.x || 2.x.x", + "clsx": "^2.1.1", + "decimal.js-light": "^2.5.1", + "es-toolkit": "^1.39.3", + "eventemitter3": "^5.0.1", + "immer": "^10.1.1", + "react-redux": "8.x.x || 9.x.x", + "reselect": "5.1.1", + "tiny-invariant": "^1.3.3", + "use-sync-external-store": "^1.2.2", + "victory-vendor": "^37.0.2" }, "engines": { - "node": ">=14" + "node": ">=18" }, "peerDependencies": { - "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, - "node_modules/recharts-scale": { - "version": "0.4.5", - "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", - "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", "license": "MIT", - "dependencies": { - "decimal.js-light": "^2.4.1" - } + "peer": true }, - "node_modules/recharts/node_modules/react-is": { - "version": "18.3.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", - "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", - "license": "MIT" + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } }, "node_modules/reflect.getprototypeof": { "version": "1.0.10", @@ -12632,9 +11962,9 @@ "license": "ISC" }, "node_modules/three": { - "version": "0.175.0", - "resolved": "https://registry.npmjs.org/three/-/three-0.175.0.tgz", - "integrity": "sha512-nNE3pnTHxXN/Phw768u0Grr7W4+rumGg/H6PgeseNJojkJtmeHJfZWi41Gp2mpXl1pg1pf1zjwR4McM1jTqkpg==", + "version": "0.182.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.182.0.tgz", + "integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==", "license": "MIT" }, "node_modules/tiny-invariant": { @@ -13035,6 +12365,37 @@ "node": ">= 0.8" } }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -13083,9 +12444,9 @@ } }, "node_modules/victory-vendor": { - "version": "36.9.2", - "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", - "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", + "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", "license": "MIT AND ISC", "dependencies": { "@types/d3-array": "^3.0.3", @@ -13600,6 +12961,13 @@ "dev": true, "license": "MIT" }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -13618,9 +12986,23 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.2.1.tgz", "integrity": "sha512-0wZ1IRqGGhMP76gLqz8EyfBXKk0J2qo2+H3fi4mcUP/KtTocoX08nmIAHl1Z2kJIZbZee8KOpBCSNPRgauucjw==", "license": "MIT", + "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } } } } diff --git a/frontends/nextjs/package.json b/frontends/nextjs/package.json index 76d32de38..b5f093466 100644 --- a/frontends/nextjs/package.json +++ b/frontends/nextjs/package.json @@ -56,26 +56,26 @@ "@aws-sdk/client-s3": "^3.958.0", "@aws-sdk/lib-storage": "^3.958.0", "@aws-sdk/s3-request-presigner": "^3.958.0", - "@emotion/react": "^11.13.5", - "@emotion/styled": "^11.13.5", + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.1", "@github/spark": ">=0.43.1 <1", - "@hookform/resolvers": "^4.1.3", + "@hookform/resolvers": "^5.2.2", "@monaco-editor/react": "^4.7.0", - "@mui/icons-material": "^6.3.1", - "@mui/material": "^6.3.1", - "@mui/x-data-grid": "^7.24.2", - "@mui/x-date-pickers": "^7.24.1", + "@mui/icons-material": "^7.3.6", + "@mui/material": "^7.3.6", + "@mui/x-data-grid": "^8.23.0", + "@mui/x-date-pickers": "^8.23.0", "@next/third-parties": "^16.1.1", - "@octokit/core": "^6.1.4", + "@octokit/core": "^7.0.6", "@phosphor-icons/react": "^2.1.10", "@prisma/client": "^6.19.1", - "@tanstack/react-query": "^5.83.1", - "@types/jszip": "^3.4.0", + "@tanstack/react-query": "^5.90.12", + "@types/jszip": "^3.4.1", "d3": "^7.9.0", - "date-fns": "^3.6.0", + "date-fns": "^4.1.0", "fengari-interop": "^0.1.4", "fengari-web": "^0.1.4", - "framer-motion": "^12.6.2", + "framer-motion": "^12.23.26", "jszip": "^3.10.1", "marked": "^17.0.1", "next": "16.1.1", @@ -84,11 +84,11 @@ "react-dom": "19.2.3", "react-error-boundary": "^6.0.0", "react-hook-form": "^7.69.0", - "recharts": "^2.15.1", + "recharts": "^3.6.0", "server-only": "^0.0.1", "sharp": "^0.34.5", "sonner": "^2.0.7", - "three": "^0.175.0", + "three": "^0.182.0", "uuid": "^13.0.0", "zod": "^4.2.1" }, @@ -98,15 +98,15 @@ "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.1", "@types/node": "^25.0.3", - "@types/react": "^19.0.10", - "@types/react-dom": "^19.0.4", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", "@vitejs/plugin-react-swc": "^4.2.2", "@vitest/coverage-v8": "^4.0.16", "dotenv": "^17.2.3", - "eslint": "^9.28.0", + "eslint": "^9.39.2", "eslint-plugin-react": "^7.37.5", - "eslint-plugin-react-hooks": "^5.2.0", - "eslint-plugin-react-refresh": "^0.4.19", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.26", "globals": "^16.5.0", "jsdom": "^27.3.0", "prisma": "^6.19.1", diff --git a/frontends/nextjs/src/app/_components/auth-provider/auth-provider-component.tsx b/frontends/nextjs/src/app/_components/auth-provider/auth-provider-component.tsx index 8ee84f6cb..55206222d 100644 --- a/frontends/nextjs/src/app/_components/auth-provider/auth-provider-component.tsx +++ b/frontends/nextjs/src/app/_components/auth-provider/auth-provider-component.tsx @@ -3,6 +3,10 @@ import { useEffect, useState } from 'react' import { useRouter } from 'next/navigation' import type { User } from '@/lib/level-types' +import { fetchSession } from '@/lib/auth/api/fetch-session' +import { login as loginRequest } from '@/lib/auth/api/login' +import { logout as logoutRequest } from '@/lib/auth/api/logout' +import { register as registerRequest } from '@/lib/auth/api/register' import { AuthContext } from './auth-context' export function AuthProvider({ children }: { children: React.ReactNode }) { @@ -17,11 +21,8 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { const checkAuth = async () => { try { - const res = await fetch('/api/auth/session') - if (res.ok) { - const data = await res.json() - setUser(data.user) - } + const sessionUser = await fetchSession() + setUser(sessionUser) } catch (error) { console.error('Auth check failed:', error) } finally { @@ -30,26 +31,15 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } const login = async (username: string, password: string) => { - const res = await fetch('/api/auth/login', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ username, password }), - }) - - if (!res.ok) { - const error = await res.json() - throw new Error(error.message || 'Login failed') - } - - const data = await res.json() - setUser(data.user) + const authenticated = await loginRequest(username, password) + setUser(authenticated) // Redirect based on role - if (data.user.role === 'supergod') { + if (authenticated.role === 'supergod') { router.push('/(auth)/supergod') - } else if (data.user.role === 'god') { + } else if (authenticated.role === 'god') { router.push('/(auth)/builder') - } else if (data.user.role === 'admin') { + } else if (authenticated.role === 'admin') { router.push('/(auth)/admin') } else { router.push('/(auth)/dashboard') @@ -57,24 +47,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { } const register = async (username: string, email: string, password: string) => { - const res = await fetch('/api/auth/register', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ username, email, password }), - }) - - if (!res.ok) { - const error = await res.json() - throw new Error(error.message || 'Registration failed') - } - - const data = await res.json() - setUser(data.user) + const registered = await registerRequest(username, email, password) + setUser(registered) router.push('/(auth)/dashboard') } const logout = async () => { - await fetch('/api/auth/logout', { method: 'POST' }) + await logoutRequest() setUser(null) router.push('/') } diff --git a/frontends/nextjs/src/app/api/auth/login/route.ts b/frontends/nextjs/src/app/api/auth/login/route.ts new file mode 100644 index 000000000..6b66d7175 --- /dev/null +++ b/frontends/nextjs/src/app/api/auth/login/route.ts @@ -0,0 +1,66 @@ +import { NextResponse } from 'next/server' +import { readJson } from '@/lib/api/read-json' +import { verifyCredentials } from '@/lib/db/credentials/verify-credentials' +import { getUserByEmail } from '@/lib/db/users/get-user-by-email' +import { getUserByUsername } from '@/lib/db/users/get-user-by-username' +import { createSession } from '@/lib/db/sessions/create-session' +import { DEFAULT_SESSION_TTL_MS } from '@/lib/auth/session-constants' +import { setSessionCookie } from '@/lib/auth/set-session-cookie' + +interface LoginPayload { + identifier?: string + username?: string + email?: string + password?: string + tenantId?: string +} + +export async function POST(request: Request) { + const body = await readJson(request) + + if (!body) { + return NextResponse.json({ error: 'Invalid JSON payload' }, { status: 400 }) + } + + const identifier = [body.identifier, body.username, body.email] + .find((value) => typeof value === 'string' && value.trim().length > 0) + ?.trim() + const password = typeof body.password === 'string' ? body.password : '' + const tenantId = typeof body.tenantId === 'string' && body.tenantId.trim() + ? body.tenantId.trim() + : undefined + + if (!identifier || !password) { + return NextResponse.json( + { error: 'Username/email and password are required' }, + { status: 400 } + ) + } + + const lookupOptions = tenantId ? { tenantId } : undefined + let user = identifier.includes('@') + ? await getUserByEmail(identifier, lookupOptions) + : await getUserByUsername(identifier, lookupOptions) + + if (!user) { + user = identifier.includes('@') + ? await getUserByUsername(identifier, lookupOptions) + : await getUserByEmail(identifier, lookupOptions) + } + + if (!user) { + return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 }) + } + + const isValid = await verifyCredentials(user.username, password) + if (!isValid) { + return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 }) + } + + const expiresAt = Date.now() + DEFAULT_SESSION_TTL_MS + const session = await createSession({ userId: user.id, expiresAt }) + + const response = NextResponse.json({ user }) + setSessionCookie(response, session.token, { maxAgeMs: DEFAULT_SESSION_TTL_MS }) + return response +} diff --git a/frontends/nextjs/src/app/api/auth/logout/route.ts b/frontends/nextjs/src/app/api/auth/logout/route.ts new file mode 100644 index 000000000..1b5660280 --- /dev/null +++ b/frontends/nextjs/src/app/api/auth/logout/route.ts @@ -0,0 +1,18 @@ +import { cookies } from 'next/headers' +import { NextResponse } from 'next/server' +import { deleteSessionByToken } from '@/lib/db/sessions/delete-session-by-token' +import { clearSessionCookie } from '@/lib/auth/clear-session-cookie' +import { AUTH_COOKIE_NAME } from '@/lib/auth/session-constants' + +export async function POST() { + const cookieStore = cookies() + const token = cookieStore.get(AUTH_COOKIE_NAME)?.value + + if (token) { + await deleteSessionByToken(token) + } + + const response = NextResponse.json({ success: true }) + clearSessionCookie(response) + return response +} diff --git a/frontends/nextjs/src/app/api/auth/register/route.ts b/frontends/nextjs/src/app/api/auth/register/route.ts new file mode 100644 index 000000000..c0c5040f8 --- /dev/null +++ b/frontends/nextjs/src/app/api/auth/register/route.ts @@ -0,0 +1,82 @@ +import { randomUUID } from 'crypto' +import { NextResponse } from 'next/server' +import { readJson } from '@/lib/api/read-json' +import { addUser } from '@/lib/db/users/add-user' +import { getUserByEmail } from '@/lib/db/users/get-user-by-email' +import { getUserByUsername } from '@/lib/db/users/get-user-by-username' +import { hashPassword } from '@/lib/db/hash-password' +import { setCredential } from '@/lib/db/credentials/set-credential' +import { createSession } from '@/lib/db/sessions/create-session' +import { DEFAULT_SESSION_TTL_MS } from '@/lib/auth/session-constants' +import { setSessionCookie } from '@/lib/auth/set-session-cookie' +import type { UserRole } from '@/lib/level-types' + +interface RegisterPayload { + username?: string + email?: string + password?: string + tenantId?: string + profilePicture?: string + bio?: string +} + +const DEFAULT_ROLE: UserRole = 'user' + +export async function POST(request: Request) { + const body = await readJson(request) + + if (!body) { + return NextResponse.json({ error: 'Invalid JSON payload' }, { status: 400 }) + } + + const username = typeof body.username === 'string' ? body.username.trim() : '' + const email = typeof body.email === 'string' ? body.email.trim() : '' + const password = typeof body.password === 'string' ? body.password : '' + const tenantId = typeof body.tenantId === 'string' && body.tenantId.trim() + ? body.tenantId.trim() + : undefined + + if (!username || !email || !password) { + return NextResponse.json( + { error: 'Username, email, and password are required' }, + { status: 400 } + ) + } + + const lookupOptions = tenantId ? { tenantId } : undefined + const [existingByUsername, existingByEmail] = await Promise.all([ + getUserByUsername(username, lookupOptions), + getUserByEmail(email, lookupOptions), + ]) + + if (existingByUsername || existingByEmail) { + return NextResponse.json( + { error: 'User already exists' }, + { status: 409 } + ) + } + + const user = { + id: randomUUID(), + username, + email, + role: DEFAULT_ROLE, + profilePicture: body.profilePicture, + bio: body.bio, + createdAt: Date.now(), + tenantId, + isInstanceOwner: false, + } + + await addUser(user) + + const passwordHash = await hashPassword(password) + await setCredential(username, passwordHash) + + const expiresAt = Date.now() + DEFAULT_SESSION_TTL_MS + const session = await createSession({ userId: user.id, expiresAt }) + + const response = NextResponse.json({ user }, { status: 201 }) + setSessionCookie(response, session.token, { maxAgeMs: DEFAULT_SESSION_TTL_MS }) + return response +} diff --git a/frontends/nextjs/src/app/api/auth/session/route.ts b/frontends/nextjs/src/app/api/auth/session/route.ts new file mode 100644 index 000000000..d62471e89 --- /dev/null +++ b/frontends/nextjs/src/app/api/auth/session/route.ts @@ -0,0 +1,45 @@ +import { cookies } from 'next/headers' +import { NextResponse } from 'next/server' +import { AUTH_COOKIE_NAME, DEFAULT_SESSION_TTL_MS } from '@/lib/auth/session-constants' +import { clearSessionCookie } from '@/lib/auth/clear-session-cookie' +import { setSessionCookie } from '@/lib/auth/set-session-cookie' +import { getSessionByToken } from '@/lib/db/sessions/get-session-by-token' +import { updateSession } from '@/lib/db/sessions/update-session' +import { deleteSession } from '@/lib/db/sessions/delete-session' +import { getUserById } from '@/lib/db/users/get-user-by-id' + +export async function GET() { + const cookieStore = cookies() + const token = cookieStore.get(AUTH_COOKIE_NAME)?.value + + if (!token) { + return NextResponse.json({ user: null }) + } + + const session = await getSessionByToken(token) + if (!session) { + const response = NextResponse.json({ user: null }) + clearSessionCookie(response) + return response + } + + const user = await getUserById(session.userId) + if (!user) { + await deleteSession(session.id) + const response = NextResponse.json({ user: null }) + clearSessionCookie(response) + return response + } + + const now = Date.now() + const refreshedExpiry = now + DEFAULT_SESSION_TTL_MS + + await updateSession(session.id, { + lastActivity: now, + expiresAt: refreshedExpiry, + }) + + const response = NextResponse.json({ user }) + setSessionCookie(response, session.token, { maxAgeMs: DEFAULT_SESSION_TTL_MS }) + return response +} diff --git a/frontends/nextjs/src/app/api/github/actions/runs/[runId]/logs/route.ts b/frontends/nextjs/src/app/api/github/actions/runs/[runId]/logs/route.ts index 73b6d8228..605be8767 100644 --- a/frontends/nextjs/src/app/api/github/actions/runs/[runId]/logs/route.ts +++ b/frontends/nextjs/src/app/api/github/actions/runs/[runId]/logs/route.ts @@ -2,54 +2,42 @@ import { NextResponse } from 'next/server' import type { NextRequest } from 'next/server' import { createGitHubClient } from '@/lib/github/create-github-client' import { fetchWorkflowRunLogs } from '@/lib/github/fetch-workflow-run-logs' +import { parseWorkflowRunLogsOptions } from '@/lib/github/parse-workflow-run-logs-options' import { resolveGitHubRepo } from '@/lib/github/resolve-github-repo' -export async function GET( - request: NextRequest, - { params }: { params: { runId: string } } -) { +interface RouteParams { + params: { + runId: string + } +} + +export const GET = async (request: NextRequest, { params }: RouteParams) => { + const runId = Number(params.runId) + if (!Number.isFinite(runId) || runId <= 0) { + return NextResponse.json({ error: 'Invalid run id' }, { status: 400 }) + } + try { - const runId = Number(params.runId) - if (!Number.isFinite(runId)) { - return NextResponse.json({ error: 'Invalid run id' }, { status: 400 }) - } - const { owner, repo } = resolveGitHubRepo(request.nextUrl.searchParams) - const runName = request.nextUrl.searchParams.get('runName')?.trim() || `Run ${runId}` - const includeLogsParam = request.nextUrl.searchParams.get('includeLogs') - const includeLogs = includeLogsParam - ? !['false', '0', 'no'].includes(includeLogsParam.toLowerCase()) - : true - const jobLimitParam = request.nextUrl.searchParams.get('jobLimit') - let jobLimit = 20 - - if (jobLimitParam) { - const parsed = Number(jobLimitParam) - if (!Number.isNaN(parsed)) { - jobLimit = Math.max(1, Math.min(100, Math.floor(parsed))) - } - } - + const { runName, includeLogs, jobLimit } = parseWorkflowRunLogsOptions( + request.nextUrl.searchParams + ) const client = createGitHubClient() + const { jobs, logsText, truncated } = await fetchWorkflowRunLogs({ client, owner, repo, - runId, + runId: Math.floor(runId), runName, - jobLimit, includeLogs, + jobLimit, }) return NextResponse.json({ - owner, - repo, - runId, - runName, jobs, logsText, truncated, - fetchedAt: new Date().toISOString(), }) } catch (error) { const status = typeof error === 'object' && error && 'status' in error diff --git a/frontends/nextjs/src/app/api/github/actions/runs/route.ts b/frontends/nextjs/src/app/api/github/actions/runs/route.ts index 9d85af159..51af8aacf 100644 --- a/frontends/nextjs/src/app/api/github/actions/runs/route.ts +++ b/frontends/nextjs/src/app/api/github/actions/runs/route.ts @@ -4,7 +4,7 @@ import { createGitHubClient } from '@/lib/github/create-github-client' import { resolveGitHubRepo } from '@/lib/github/resolve-github-repo' import { listWorkflowRuns } from '@/lib/github/list-workflow-runs' -export async function GET(request: NextRequest) { +export const GET = async (request: NextRequest) => { try { const { owner, repo } = resolveGitHubRepo(request.nextUrl.searchParams) const perPageParam = request.nextUrl.searchParams.get('perPage') diff --git a/frontends/nextjs/src/components/GitHubActionsFetcher.tsx b/frontends/nextjs/src/components/GitHubActionsFetcher.tsx index a0416983d..6ca2b7afd 100644 --- a/frontends/nextjs/src/components/GitHubActionsFetcher.tsx +++ b/frontends/nextjs/src/components/GitHubActionsFetcher.tsx @@ -1,13 +1,39 @@ -import { useState, useEffect, useMemo } from 'react' -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui' -import { Button } from '@/components/ui' -import { Alert, AlertDescription, AlertTitle } from '@/components/ui' -import { Skeleton } from '@/components/ui' -import { Badge } from '@/components/ui' -import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui' -import { CheckCircle, XCircle, ArrowClockwise, ArrowSquareOut, Info, Warning, TrendUp, TrendDown, Robot, Download, FileText } from '@phosphor-icons/react' +import { useEffect, useMemo, useState } from 'react' +import { Box, Stack, Typography } from '@mui/material' +import { alpha } from '@mui/material/styles' +import { + Autorenew as RunningIcon, + Cancel as FailureIcon, + CheckCircle as SuccessIcon, + Description as FileTextIcon, + Download as DownloadIcon, + Info as InfoIcon, + OpenInNew as OpenInNewIcon, + Refresh as RefreshIcon, + SmartToy as RobotIcon, + TrendingDown as TrendDownIcon, + TrendingUp as TrendUpIcon, + Warning as WarningIcon, +} from '@mui/icons-material' +import { + Alert, + AlertDescription, + AlertTitle, + Badge, + Button, + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, + ScrollArea, + Skeleton, + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from '@/components/ui' import { toast } from 'sonner' -import { ScrollArea } from '@/components/ui' import { formatWorkflowRunAnalysis, summarizeWorkflowRuns } from '@/lib/github/analyze-workflow-runs' import { formatWorkflowLogAnalysis, summarizeWorkflowLogs } from '@/lib/github/analyze-workflow-logs' @@ -43,6 +69,22 @@ interface JobStep { completed_at?: string | null } +const spinSx = { + animation: 'spin 1s linear infinite', + '@keyframes spin': { + from: { transform: 'rotate(0deg)' }, + to: { transform: 'rotate(360deg)' }, + }, +} + +const pulseSx = { + animation: 'pulse 1.4s ease-in-out infinite', + '@keyframes pulse': { + '0%, 100%': { opacity: 0.6 }, + '50%': { opacity: 1 }, + }, +} + export function GitHubActionsFetcher() { const [data, setData] = useState(null) const [isLoading, setIsLoading] = useState(false) @@ -129,18 +171,27 @@ export function GitHubActionsFetcher() { const getStatusColor = (status: string, conclusion: string | null) => { if (status === 'completed') { - if (conclusion === 'success') return 'text-green-600' - if (conclusion === 'failure') return 'text-red-600' - if (conclusion === 'cancelled') return 'text-gray-600' + if (conclusion === 'success') return 'success.main' + if (conclusion === 'failure') return 'error.main' + if (conclusion === 'cancelled') return 'text.secondary' } - return 'text-yellow-600' + return 'warning.main' } const getStatusIcon = (status: string, conclusion: string | null) => { - if (status === 'completed' && conclusion === 'success') { - return + if (status === 'completed') { + if (conclusion === 'success') { + return + } + if (conclusion === 'failure') { + return + } + if (conclusion === 'cancelled') { + return + } } - return + + return } const analyzeWorkflows = async () => { @@ -165,16 +216,16 @@ export function GitHubActionsFetcher() { const downloadWorkflowData = () => { if (!data) return - + const jsonData = JSON.stringify(data, null, 2) const blob = new Blob([jsonData], { type: 'application/json' }) const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = `github-actions-${new Date().toISOString()}.json` - document.body.appendChild(a) - a.click() - document.body.removeChild(a) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = `github-actions-${new Date().toISOString()}.json` + document.body.appendChild(anchor) + anchor.click() + document.body.removeChild(anchor) URL.revokeObjectURL(url) toast.success('Downloaded workflow data') } @@ -229,12 +280,12 @@ export function GitHubActionsFetcher() { if (logsText) { const blob = new Blob([logsText], { type: 'text/plain' }) const url = URL.createObjectURL(blob) - const a = document.createElement('a') - a.href = url - a.download = `workflow-logs-${runId}-${new Date().toISOString()}.txt` - document.body.appendChild(a) - a.click() - document.body.removeChild(a) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = `workflow-logs-${runId}-${new Date().toISOString()}.txt` + document.body.appendChild(anchor) + anchor.click() + document.body.removeChild(anchor) URL.revokeObjectURL(url) } @@ -285,30 +336,30 @@ export function GitHubActionsFetcher() { const failed = data.filter(r => r.status === 'completed' && r.conclusion === 'failure').length const cancelled = data.filter(r => r.status === 'completed' && r.conclusion === 'cancelled').length const inProgress = data.filter(r => r.status !== 'completed').length - + const mostRecent = data[0] const mostRecentTimestamp = new Date(mostRecent.updated_at).getTime() const timeThreshold = 5 * 60 * 1000 - + const recentWorkflows = data.filter(r => { const runTime = new Date(r.updated_at).getTime() return Math.abs(runTime - mostRecentTimestamp) < timeThreshold }) - + const hasAnyFailed = recentWorkflows.some(r => r.status === 'completed' && r.conclusion === 'failure') const hasAnyRunning = recentWorkflows.some(r => r.status !== 'completed') const allPassed = recentWorkflows.every(r => r.status === 'completed' && r.conclusion === 'success') - + const mostRecentPassed = allPassed && recentWorkflows.length > 0 const mostRecentFailed = hasAnyFailed const mostRecentRunning = hasAnyRunning && !hasAnyFailed - + const successRate = completed > 0 ? Math.round((successful / completed) * 100) : 0 const recentRuns = data.slice(0, 5) const recentCompleted = recentRuns.filter(r => r.status === 'completed') const recentSuccessful = recentCompleted.filter(r => r.conclusion === 'success').length const recentFailed = recentCompleted.filter(r => r.conclusion === 'failure').length - + const health = successRate >= 80 ? 'healthy' : successRate >= 60 ? 'warning' : 'critical' const trend = recentSuccessful >= recentFailed ? 'up' : 'down' @@ -328,40 +379,77 @@ export function GitHubActionsFetcher() { mostRecentPassed, mostRecentFailed, mostRecentRunning, - recentWorkflows + recentWorkflows, } }, [data]) + const summaryTone = conclusion + ? conclusion.mostRecentPassed + ? 'success' + : conclusion.mostRecentFailed + ? 'error' + : 'warning' + : 'warning' + return ( -
-
-
-

GitHub Actions Monitor

-

- Repository: {repoLabel} -

-
-
+ + + + + GitHub Actions Monitor + + + Repository:{' '} + + {repoLabel} + + + + + -
-
- + + + + Auto-refresh {autoRefreshEnabled ? 'ON' : 'OFF'} {autoRefreshEnabled && ( - + Next refresh: {secondsUntilRefresh}s - + )} -
+ -
+
+ -
-
+ + {conclusion && ( <> - ({ + borderWidth: 2, + borderColor: theme.palette[summaryTone].main, + bgcolor: alpha(theme.palette[summaryTone].main, 0.08), + alignItems: 'flex-start', + })} > -
- {conclusion.mostRecentPassed && ( - + + {summaryTone === 'success' && ( + )} - {conclusion.mostRecentFailed && ( - + {summaryTone === 'error' && ( + )} - {conclusion.mostRecentRunning && ( - + {summaryTone === 'warning' && ( + )} -
- - {conclusion.mostRecentPassed && 'Most Recent Builds: ALL PASSED ✓'} - {conclusion.mostRecentFailed && 'Most Recent Builds: FAILURES DETECTED ✗'} - {conclusion.mostRecentRunning && 'Most Recent Builds: RUNNING...'} + + + + {conclusion.mostRecentPassed && 'Most Recent Builds: ALL PASSED'} + {conclusion.mostRecentFailed && 'Most Recent Builds: FAILURES DETECTED'} + {conclusion.mostRecentRunning && 'Most Recent Builds: RUNNING'} + - -
- {conclusion.recentWorkflows.length > 1 ? ( - Showing {conclusion.recentWorkflows.length} workflows from the most recent run: - ) : ( - Most recent workflow: - )} -
- {conclusion.recentWorkflows.map((workflow) => ( -
-
- {workflow.status === 'completed' && workflow.conclusion === 'success' && ( - - )} - {workflow.status === 'completed' && workflow.conclusion === 'failure' && ( - - )} - {workflow.status !== 'completed' && ( - - )} - {workflow.name} - - {workflow.status === 'completed' ? workflow.conclusion : workflow.status} - -
-
- - Branch: - - {workflow.head_branch} - - - - Updated: - {new Date(workflow.updated_at).toLocaleString()} - -
-
- ))} -
- -
+ +
+ -
-
+ +
- + - - {conclusion.health === 'healthy' && } - {conclusion.health === 'warning' && } - {conclusion.health === 'critical' && } - Pipeline Health Summary - - - Analysis of recent workflow runs - + + {conclusion.health === 'healthy' && ( + + )} + {conclusion.health === 'warning' && ( + + )} + {conclusion.health === 'critical' && ( + + )} + Pipeline Health Summary + + Analysis of recent workflow runs -
-
- + + {conclusion.successRate}% Success Rate - - - + + + {conclusion.successful} Passed - + {conclusion.failed > 0 && ( - - + + {conclusion.failed} Failed )} - + {conclusion.inProgress > 0 && ( - - + + {conclusion.inProgress} Running )} - + {conclusion.cancelled > 0 && ( - + {conclusion.cancelled} Cancelled )} - {conclusion.trend === 'up' ? ( - + ) : ( - + )} Recent: {conclusion.recentSuccessful}/{conclusion.recentSuccessful + conclusion.recentFailed} -
+ -
+ {conclusion.health === 'healthy' && ( -

- - Pipeline is healthy. Most recent runs are passing consistently. -

+ + + + Pipeline is healthy. Most recent runs are passing consistently. + + )} {conclusion.health === 'warning' && ( -

- - Pipeline health is moderate. Some failures detected in recent runs. -

+ + + + Pipeline health is moderate. Some failures detected in recent runs. + + )} {conclusion.health === 'critical' && ( -

- - Pipeline health is critical. High failure rate detected. -

+ + + + Pipeline health is critical. High failure rate detected. + + )} -
-
+ +
@@ -561,128 +699,180 @@ export function GitHubActionsFetcher() { {needsAuth && ( - - Authentication Note - - This app uses the GitHub API to fetch workflow data. The public API allows anonymous access with rate limits. - + + + + Authentication Note + + This app uses the GitHub API to fetch workflow data. The public API allows anonymous access with rate + limits. + + + )} {lastFetched && ( - - Last Fetched - - {lastFetched.toLocaleString()} - + + + + Last Fetched + {lastFetched.toLocaleString()} + + )} {error && ( - - Error - {error} + + + + Error + {error} + + )} - + Workflow Runs {runLogs && Downloaded Logs} AI Analysis - + - - Workflow Runs - + Workflow Runs + + + Recent workflow runs via GitHub REST API {isLoading ? ( -
- - - - -
+ + + + + + ) : data && data.length > 0 ? ( -
- {data.map((run) => ( - - -
-
-
- {getStatusIcon(run.status, run.conclusion)} -

{run.name}

-
-
- - Branch: - {run.head_branch} - - - Event: - {run.event} - - - Status: - - {run.status === 'completed' ? run.conclusion : run.status} - - -
-
- Updated: {new Date(run.updated_at).toLocaleString()} -
-
-
- - + -
-
-
-
- ))} -
+ + + + + + ) + })} + -
-
+ + ) : ( -
+ No workflow runs found. Click refresh to fetch data. -
+ )}
{runLogs && ( - + - - - Workflow Logs + + + Workflow Logs {selectedRunId && ( - Run #{selectedRunId} + + Run #{selectedRunId} + )} - + Complete logs from workflow run including all jobs and steps - - {runJobs.length > 0 && ( -
-

Jobs Summary

-
- {runJobs.map((job) => ( - - {job.name}: {job.conclusion || job.status} - - ))} -
-
- )} + + + {runJobs.length > 0 && ( + + Jobs Summary + + {runJobs.map((job) => ( + + {job.name}: {job.conclusion || job.status} + + ))} + + + )} - -
-                    {runLogs}
-                  
-
- -
- - -
+ + {runLogs} + + + + + + + +
)} - + - - - AI-Powered Workflow Analysis - + + + AI-Powered Workflow Analysis + - {runLogs - ? 'Deep analysis of downloaded workflow logs using GPT-4' + {runLogs + ? 'Deep analysis of downloaded workflow logs using GPT-4' : 'Deep analysis of your CI/CD pipeline using GPT-4'} - - {runLogs ? ( - - ) : ( - - )} + + + {runLogs ? ( + + ) : ( + + )} - {isAnalyzing && ( -
- - - -
- )} + {isAnalyzing && ( + + + + + + )} - {analysis && !isAnalyzing && ( -
-
+ {analysis && !isAnalyzing && ( + {analysis} -
-
- )} + + )} - {!analysis && !isAnalyzing && ( - - - No Analysis Yet - - {runLogs - ? 'Click the button above to run an AI analysis of the downloaded logs. The AI will identify errors, provide root cause analysis, and suggest fixes.' - : 'Download logs from a specific workflow run using the "Download Logs" button, or click above to analyze overall workflow patterns.'} - - - )} + {!analysis && !isAnalyzing && ( + + + + + No Analysis Yet + + {runLogs + ? 'Click the button above to run an AI analysis of the downloaded logs. The AI will identify errors, provide root cause analysis, and suggest fixes.' + : 'Download logs from a specific workflow run using the "Download Logs" button, or click above to analyze overall workflow patterns.'} + + + + + )} +
-
+ ) } diff --git a/frontends/nextjs/src/components/ui/README.md b/frontends/nextjs/src/components/ui/README.md index 49877a478..25fcd16f5 100644 --- a/frontends/nextjs/src/components/ui/README.md +++ b/frontends/nextjs/src/components/ui/README.md @@ -58,6 +58,9 @@ Simple groups of atoms working together: - DropdownMenu, RadioGroup, Popover - ToggleGroup, Breadcrumb +### Tabs +`Tabs` manages selection state. Provide `defaultValue` for uncontrolled usage or `value`/`onValueChange` for controlled usage. `TabsContent` hides inactive panels while keeping them mounted. + ### Organisms Complex, distinct UI sections: - Table, Form, Sheet diff --git a/frontends/nextjs/src/components/ui/molecules/Tabs.tsx b/frontends/nextjs/src/components/ui/molecules/Tabs.tsx index 15c179df4..ba8612887 100644 --- a/frontends/nextjs/src/components/ui/molecules/Tabs.tsx +++ b/frontends/nextjs/src/components/ui/molecules/Tabs.tsx @@ -1,132 +1,8 @@ -'use client' - -import { forwardRef, ReactNode, useState } from 'react' -import { Box } from '@mui/material' - -interface TabsProps { - children: ReactNode - defaultValue?: string - value?: string - onValueChange?: (value: string) => void - className?: string -} - -const Tabs = forwardRef( - ({ children, defaultValue, value, onValueChange, ...props }, ref) => { - const [internalValue, setInternalValue] = useState(defaultValue ?? '') - const _currentValue = value ?? internalValue - - const _handleChange = (_newValue: string) => { - if (!value) setInternalValue(_newValue) - onValueChange?.(_newValue) - } - - return ( - - {/* Pass value down through children */} - {children} - - ) - } -) -Tabs.displayName = 'Tabs' - -interface TabsListProps { - children: ReactNode - className?: string -} - -const TabsList = forwardRef( - ({ children, ...props }, ref) => { - return ( - - {children} - - ) - } -) -TabsList.displayName = 'TabsList' - -interface TabsTriggerProps { - children: ReactNode - value: string - disabled?: boolean - className?: string -} - -const TabsTrigger = forwardRef( - ({ children, value, disabled, ...props }, ref) => { - return ( - - {children} - - ) - } -) -TabsTrigger.displayName = 'TabsTrigger' - -interface TabsContentProps { - children: ReactNode - value: string - className?: string -} - -const TabsContent = forwardRef( - ({ children, value, ...props }, ref) => { - return ( - - {children} - - ) - } -) -TabsContent.displayName = 'TabsContent' - -export { Tabs, TabsList, TabsTrigger, TabsContent } +export { Tabs } from './tabs/Tabs' +export type { TabsProps } from './tabs/Tabs' +export { TabsList } from './tabs/TabsList' +export type { TabsListProps } from './tabs/TabsList' +export { TabsTrigger } from './tabs/TabsTrigger' +export type { TabsTriggerProps } from './tabs/TabsTrigger' +export { TabsContent } from './tabs/TabsContent' +export type { TabsContentProps } from './tabs/TabsContent' diff --git a/frontends/nextjs/src/components/ui/molecules/tabs/Tabs.test.tsx b/frontends/nextjs/src/components/ui/molecules/tabs/Tabs.test.tsx new file mode 100644 index 000000000..1e7ebf702 --- /dev/null +++ b/frontends/nextjs/src/components/ui/molecules/tabs/Tabs.test.tsx @@ -0,0 +1,34 @@ +import { render, screen, fireEvent } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui' + +describe('Tabs', () => { + it('switches active tab and hides inactive panels', () => { + render( + + + Alpha + Beta + + Alpha content + Beta content + + ) + + const alphaPanel = screen.getByText('Alpha content').closest('[role="tabpanel"]') + const betaPanel = screen.getByText('Beta content').closest('[role="tabpanel"]') + + expect(alphaPanel?.hasAttribute('hidden')).toBe(false) + expect(betaPanel?.hasAttribute('hidden')).toBe(true) + + const betaTab = screen.getByRole('tab', { name: 'Beta' }) + fireEvent.click(betaTab) + + expect(alphaPanel?.hasAttribute('hidden')).toBe(true) + expect(betaPanel?.hasAttribute('hidden')).toBe(false) + + const alphaTab = screen.getByRole('tab', { name: 'Alpha' }) + expect(alphaTab.getAttribute('aria-selected')).toBe('false') + expect(betaTab.getAttribute('aria-selected')).toBe('true') + }) +}) diff --git a/frontends/nextjs/src/components/ui/molecules/tabs/Tabs.tsx b/frontends/nextjs/src/components/ui/molecules/tabs/Tabs.tsx new file mode 100644 index 000000000..9e3693a91 --- /dev/null +++ b/frontends/nextjs/src/components/ui/molecules/tabs/Tabs.tsx @@ -0,0 +1,43 @@ +'use client' + +import { forwardRef, useId, useState } from 'react' +import { Box } from '@mui/material' +import type { BoxProps } from '@mui/material' +import { TabsContext } from './tabs-context' + +export interface TabsProps extends BoxProps { + defaultValue?: string + value?: string + onValueChange?: (value: string) => void +} + +const Tabs = forwardRef( + ({ children, defaultValue, value, onValueChange, sx, ...props }, ref) => { + const [internalValue, setInternalValue] = useState(defaultValue ?? '') + const currentValue = value ?? internalValue + const idPrefix = useId() + + const handleValueChange = (nextValue: string) => { + if (value === undefined) { + setInternalValue(nextValue) + } + onValueChange?.(nextValue) + } + + return ( + + + {children} + + + ) + } +) + +Tabs.displayName = 'Tabs' + +export { Tabs } diff --git a/frontends/nextjs/src/components/ui/molecules/tabs/TabsContent.tsx b/frontends/nextjs/src/components/ui/molecules/tabs/TabsContent.tsx new file mode 100644 index 000000000..340e7c98b --- /dev/null +++ b/frontends/nextjs/src/components/ui/molecules/tabs/TabsContent.tsx @@ -0,0 +1,45 @@ +'use client' + +import { forwardRef, useContext } from 'react' +import { Box } from '@mui/material' +import type { BoxProps } from '@mui/material' +import { TabsContext } from './tabs-context' + +export interface TabsContentProps extends BoxProps { + value: string +} + +const TabsContent = forwardRef( + ({ children, value, sx, ...props }, ref) => { + const context = useContext(TabsContext) + + if (!context) { + throw new Error('TabsContent must be used within Tabs') + } + + const isActive = context.value === value + + return ( + + ) + } +) + +TabsContent.displayName = 'TabsContent' + +export { TabsContent } diff --git a/frontends/nextjs/src/components/ui/molecules/tabs/TabsList.tsx b/frontends/nextjs/src/components/ui/molecules/tabs/TabsList.tsx new file mode 100644 index 000000000..408c16a3f --- /dev/null +++ b/frontends/nextjs/src/components/ui/molecules/tabs/TabsList.tsx @@ -0,0 +1,43 @@ +'use client' + +import { forwardRef, useContext } from 'react' +import { Box } from '@mui/material' +import type { BoxProps } from '@mui/material' +import { TabsContext } from './tabs-context' + +export interface TabsListProps extends BoxProps {} + +const TabsList = forwardRef( + ({ children, sx, ...props }, ref) => { + const context = useContext(TabsContext) + + if (!context) { + throw new Error('TabsList must be used within Tabs') + } + + return ( + + {children} + + ) + } +) + +TabsList.displayName = 'TabsList' + +export { TabsList } diff --git a/frontends/nextjs/src/components/ui/molecules/tabs/TabsTrigger.tsx b/frontends/nextjs/src/components/ui/molecules/tabs/TabsTrigger.tsx new file mode 100644 index 000000000..ab515f523 --- /dev/null +++ b/frontends/nextjs/src/components/ui/molecules/tabs/TabsTrigger.tsx @@ -0,0 +1,84 @@ +'use client' + +import { forwardRef, useContext } from 'react' +import type { MouseEvent } from 'react' +import { Box } from '@mui/material' +import type { BoxProps } from '@mui/material' +import { TabsContext } from './tabs-context' + +export interface TabsTriggerProps extends BoxProps<'button'> { + value: string +} + +const TabsTrigger = forwardRef( + ({ children, value, onClick, disabled, sx, ...props }, ref) => { + const context = useContext(TabsContext) + + if (!context) { + throw new Error('TabsTrigger must be used within Tabs') + } + + const isSelected = context.value === value + + const handleClick = (event: MouseEvent) => { + if (disabled) return + onClick?.(event) + if (!event.defaultPrevented) { + context.setValue(value) + } + } + + return ( + + {children} + + ) + } +) + +TabsTrigger.displayName = 'TabsTrigger' + +export { TabsTrigger } diff --git a/frontends/nextjs/src/components/ui/molecules/tabs/tabs-context.ts b/frontends/nextjs/src/components/ui/molecules/tabs/tabs-context.ts new file mode 100644 index 000000000..485cfe907 --- /dev/null +++ b/frontends/nextjs/src/components/ui/molecules/tabs/tabs-context.ts @@ -0,0 +1,9 @@ +import { createContext } from 'react' + +export interface TabsContextValue { + value: string + setValue: (value: string) => void + idPrefix: string +} + +export const TabsContext = createContext(null) diff --git a/frontends/nextjs/src/hooks/auth/auth-store.ts b/frontends/nextjs/src/hooks/auth/auth-store.ts new file mode 100644 index 000000000..9a5fefdbc --- /dev/null +++ b/frontends/nextjs/src/hooks/auth/auth-store.ts @@ -0,0 +1,133 @@ +import type { User } from '@/lib/level-types' +import { fetchSession } from '@/lib/auth/api/fetch-session' +import { login as loginRequest } from '@/lib/auth/api/login' +import { logout as logoutRequest } from '@/lib/auth/api/logout' +import type { AuthState, AuthUser } from './auth-types' + +const roleLevels: Record = { + public: 1, + user: 2, + admin: 3, + god: 4, + supergod: 5, +} + +export class AuthStore { + private state: AuthState = { + user: null, + isAuthenticated: false, + isLoading: false, + } + + private listeners = new Set<() => void>() + private sessionCheckPromise: Promise | null = null + + getState(): AuthState { + return this.state + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener) + return () => { + this.listeners.delete(listener) + } + } + + async ensureSessionChecked(): Promise { + if (!this.sessionCheckPromise) { + this.sessionCheckPromise = this.refresh().finally(() => { + this.sessionCheckPromise = null + }) + } + return this.sessionCheckPromise + } + + async login(identifier: string, password: string): Promise { + this.setState({ + ...this.state, + isLoading: true, + }) + + try { + const user = await loginRequest(identifier, password) + this.setState({ + user: this.mapUserToAuthUser(user), + isAuthenticated: true, + isLoading: false, + }) + } catch (error) { + this.setState({ + ...this.state, + isLoading: false, + }) + throw error + } + } + + async logout(): Promise { + this.setState({ + ...this.state, + isLoading: true, + }) + + try { + await logoutRequest() + this.setState({ + user: null, + isAuthenticated: false, + isLoading: false, + }) + } catch (error) { + this.setState({ + ...this.state, + isLoading: false, + }) + throw error + } + } + + async refresh(): Promise { + this.setState({ + ...this.state, + isLoading: true, + }) + + try { + const sessionUser = await fetchSession() + this.setState({ + user: sessionUser ? this.mapUserToAuthUser(sessionUser) : null, + isAuthenticated: Boolean(sessionUser), + isLoading: false, + }) + } catch (error) { + console.error('Failed to refresh auth session:', error) + this.setState({ + ...this.state, + isLoading: false, + }) + } + } + + private mapUserToAuthUser(user: User): AuthUser { + const level = roleLevels[user.role] + return { + id: user.id, + email: user.email, + username: user.username, + name: user.username, + role: user.role, + level, + tenantId: user.tenantId, + profilePicture: user.profilePicture, + bio: user.bio, + isInstanceOwner: user.isInstanceOwner, + } + } + + private setState(next: AuthState): void { + this.state = next + this.listeners.forEach((listener) => listener()) + } +} + +export const authStore = new AuthStore() diff --git a/frontends/nextjs/src/hooks/auth/auth-types.ts b/frontends/nextjs/src/hooks/auth/auth-types.ts new file mode 100644 index 000000000..4de6080b2 --- /dev/null +++ b/frontends/nextjs/src/hooks/auth/auth-types.ts @@ -0,0 +1,26 @@ +import type { UserRole } from '@/lib/level-types' + +export interface AuthUser { + id: string + email: string + username?: string + name?: string + role?: UserRole + level?: number + tenantId?: string + profilePicture?: string + bio?: string + isInstanceOwner?: boolean +} + +export interface AuthState { + user: AuthUser | null + isAuthenticated: boolean + isLoading: boolean +} + +export interface UseAuthReturn extends AuthState { + login: (identifier: string, password: string) => Promise + logout: () => Promise + refresh: () => Promise +} diff --git a/frontends/nextjs/src/hooks/index.ts b/frontends/nextjs/src/hooks/index.ts index 99c920496..5cddf3709 100644 --- a/frontends/nextjs/src/hooks/index.ts +++ b/frontends/nextjs/src/hooks/index.ts @@ -7,7 +7,7 @@ export { useGitHubFetcher } from './useGitHubFetcher' export { useKV } from './useKV' export { useIsMobile } from './use-mobile' -export type { AuthUser, AuthState, UseAuthReturn } from './useAuth' +export type { AuthUser, AuthState, UseAuthReturn } from './auth/auth-types' export type { EditorFile } from './useCodeEditor' export type { FileNode } from './useFileTree' export type { WorkflowRun } from './useGitHubFetcher' diff --git a/frontends/nextjs/src/hooks/useAuth.test.ts b/frontends/nextjs/src/hooks/useAuth.test.ts index 19ce3eec0..343c35880 100644 --- a/frontends/nextjs/src/hooks/useAuth.test.ts +++ b/frontends/nextjs/src/hooks/useAuth.test.ts @@ -1,37 +1,68 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' -import { renderHook, act } from '@testing-library/react' +import { describe, it, expect, beforeEach, vi } from 'vitest' +import { renderHook, act, waitFor } from '@testing-library/react' +import type { User } from '@/lib/level-types' + +const mockFetchSession = vi.fn() +const mockLogin = vi.fn() +const mockLogout = vi.fn() + +vi.mock('@/lib/auth/api/fetch-session', () => ({ + fetchSession: mockFetchSession, +})) + +vi.mock('@/lib/auth/api/login', () => ({ + login: mockLogin, +})) + +vi.mock('@/lib/auth/api/logout', () => ({ + logout: mockLogout, +})) + import { useAuth } from '@/hooks/useAuth' +const createUser = (overrides?: Partial): User => ({ + id: 'user_1', + username: 'alice', + email: 'alice@example.com', + role: 'user', + createdAt: 1000, + tenantId: undefined, + profilePicture: undefined, + bio: undefined, + isInstanceOwner: false, + ...overrides, +}) + +const waitForIdle = async (result: { current: { isLoading: boolean } }) => { + await waitFor(() => { + expect(result.current.isLoading).toBe(false) + }) +} + describe('useAuth', () => { - const completeAuthAction = async (action: () => Promise) => { - await act(async () => { - const promise = action() - vi.advanceTimersByTime(100) - await promise - }) - } - - beforeEach(() => { - vi.useFakeTimers() - }) - - afterEach(() => { - vi.runOnlyPendingTimers() - vi.useRealTimers() - }) - beforeEach(async () => { + mockFetchSession.mockReset() + mockLogin.mockReset() + mockLogout.mockReset() + mockFetchSession.mockResolvedValue(null) + mockLogout.mockResolvedValue(undefined) + const { result, unmount } = renderHook(() => useAuth()) - await completeAuthAction(() => result.current.logout()) + await waitForIdle(result) + await act(async () => { + await result.current.logout() + }) + await waitForIdle(result) unmount() }) - it('should start unauthenticated', () => { + it('should start unauthenticated after session check', async () => { const { result, unmount } = renderHook(() => useAuth()) + await waitForIdle(result) + expect(result.current.user).toBeNull() expect(result.current.isAuthenticated).toBe(false) - expect(result.current.isLoading).toBe(false) unmount() }) @@ -42,15 +73,25 @@ describe('useAuth', () => { ])('should authenticate $email', async ({ email, expectedName }) => { const { result, unmount } = renderHook(() => useAuth()) - await completeAuthAction(() => result.current.login(email, 'password')) + mockLogin.mockResolvedValue(createUser({ + id: 'user_1', + username: expectedName, + email, + })) + + await waitForIdle(result) + await act(async () => { + await result.current.login(email, 'password') + }) expect(result.current.user).toMatchObject({ - id: '1', + id: 'user_1', email, name: expectedName, + username: expectedName, + level: 2, }) expect(result.current.isAuthenticated).toBe(true) - expect(result.current.isLoading).toBe(false) unmount() }) @@ -58,12 +99,19 @@ describe('useAuth', () => { it('should clear user on logout', async () => { const { result, unmount } = renderHook(() => useAuth()) - await completeAuthAction(() => result.current.login('logout@example.com', 'password')) - await completeAuthAction(() => result.current.logout()) + mockLogin.mockResolvedValue(createUser()) + + await waitForIdle(result) + await act(async () => { + await result.current.login('alice@example.com', 'password') + }) + + await act(async () => { + await result.current.logout() + }) expect(result.current.user).toBeNull() expect(result.current.isAuthenticated).toBe(false) - expect(result.current.isLoading).toBe(false) unmount() }) @@ -72,12 +120,21 @@ describe('useAuth', () => { const first = renderHook(() => useAuth()) const second = renderHook(() => useAuth()) - await completeAuthAction(() => first.result.current.login('sync@example.com', 'password')) + mockLogin.mockResolvedValue(createUser({ email: 'sync@example.com', username: 'sync' })) + + await waitForIdle(first.result) + await waitForIdle(second.result) + + await act(async () => { + await first.result.current.login('sync@example.com', 'password') + }) expect(second.result.current.isAuthenticated).toBe(true) expect(second.result.current.user?.email).toBe('sync@example.com') - await completeAuthAction(() => second.result.current.logout()) + await act(async () => { + await second.result.current.logout() + }) expect(first.result.current.isAuthenticated).toBe(false) expect(first.result.current.user).toBeNull() diff --git a/frontends/nextjs/src/hooks/useAuth.ts b/frontends/nextjs/src/hooks/useAuth.ts index 0487e5a58..994473fb0 100644 --- a/frontends/nextjs/src/hooks/useAuth.ts +++ b/frontends/nextjs/src/hooks/useAuth.ts @@ -1,85 +1,30 @@ /** - * useAuth hook - simple authentication state management + * useAuth hook - authentication state management * Provides user authentication state and methods */ import { useState, useEffect, useCallback } from 'react' - -export interface AuthUser { - id: string - email: string - name?: string - role?: 'user' | 'admin' | 'god' | 'supergod' - level?: number -} - -export interface AuthState { - user: AuthUser | null - isAuthenticated: boolean - isLoading: boolean -} - -export interface UseAuthReturn extends AuthState { - login: (email: string, password: string) => Promise - logout: () => Promise - refresh: () => Promise -} - -// Simple in-memory auth state for now -// TODO: Implement proper auth with backend/Prisma -let authState: AuthState = { - user: null, - isAuthenticated: false, - isLoading: false, -} - -const listeners = new Set<() => void>() - -function notifyListeners() { - listeners.forEach((listener) => listener()) -} +import { authStore } from './auth/auth-store' +import type { AuthState, UseAuthReturn } from './auth/auth-types' export function useAuth(): UseAuthReturn { - const [state, setState] = useState(authState) + const [state, setState] = useState(authStore.getState()) useEffect(() => { - const listener = () => setState({ ...authState }) - listeners.add(listener) - return () => { - listeners.delete(listener) - } + const unsubscribe = authStore.subscribe(() => setState({ ...authStore.getState() })) + void authStore.ensureSessionChecked() + return unsubscribe }, []) - const login = useCallback(async (email: string, _password: string) => { - authState = { ...authState, isLoading: true } - notifyListeners() - - // Simulate API call - await new Promise((resolve) => setTimeout(resolve, 100)) - - authState = { - user: { id: '1', email, name: email.split('@')[0] }, - isAuthenticated: true, - isLoading: false, - } - notifyListeners() + const login = useCallback(async (identifier: string, password: string) => { + await authStore.login(identifier, password) }, []) const logout = useCallback(async () => { - authState = { ...authState, isLoading: true } - notifyListeners() - - await new Promise((resolve) => setTimeout(resolve, 100)) - - authState = { - user: null, - isAuthenticated: false, - isLoading: false, - } - notifyListeners() + await authStore.logout() }, []) const refresh = useCallback(async () => { - // No-op for now, would refresh token/session + await authStore.refresh() }, []) return { diff --git a/frontends/nextjs/src/lib/api/read-json.ts b/frontends/nextjs/src/lib/api/read-json.ts new file mode 100644 index 000000000..d9f08a165 --- /dev/null +++ b/frontends/nextjs/src/lib/api/read-json.ts @@ -0,0 +1,7 @@ +export async function readJson(request: Request): Promise { + try { + return (await request.json()) as T + } catch { + return null + } +} diff --git a/frontends/nextjs/src/lib/auth/api/fetch-session.ts b/frontends/nextjs/src/lib/auth/api/fetch-session.ts new file mode 100644 index 000000000..2ed9e508c --- /dev/null +++ b/frontends/nextjs/src/lib/auth/api/fetch-session.ts @@ -0,0 +1,13 @@ +import type { User } from '@/lib/level-types' + +export async function fetchSession(): Promise { + try { + const response = await fetch('/api/auth/session', { cache: 'no-store' }) + if (!response.ok) return null + const payload = (await response.json()) as { user?: User | null } | null + return payload?.user ?? null + } catch (error) { + console.error('Failed to fetch session:', error) + return null + } +} diff --git a/frontends/nextjs/src/lib/auth/api/index.ts b/frontends/nextjs/src/lib/auth/api/index.ts new file mode 100644 index 000000000..42fcf5eae --- /dev/null +++ b/frontends/nextjs/src/lib/auth/api/index.ts @@ -0,0 +1,4 @@ +export { fetchSession } from './fetch-session' +export { login } from './login' +export { logout } from './logout' +export { register } from './register' diff --git a/frontends/nextjs/src/lib/auth/api/login.ts b/frontends/nextjs/src/lib/auth/api/login.ts new file mode 100644 index 000000000..b0934bbaa --- /dev/null +++ b/frontends/nextjs/src/lib/auth/api/login.ts @@ -0,0 +1,22 @@ +import type { User } from '@/lib/level-types' + +export async function login(identifier: string, password: string, options?: { tenantId?: string }): Promise { + const response = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + identifier, + password, + tenantId: options?.tenantId, + }), + }) + + const payload = (await response.json().catch(() => null)) as { user?: User; error?: string } | null + + if (!response.ok || !payload?.user) { + const message = payload?.error || 'Login failed' + throw new Error(message) + } + + return payload.user +} diff --git a/frontends/nextjs/src/lib/auth/api/logout.ts b/frontends/nextjs/src/lib/auth/api/logout.ts new file mode 100644 index 000000000..d7e8c5a73 --- /dev/null +++ b/frontends/nextjs/src/lib/auth/api/logout.ts @@ -0,0 +1,8 @@ +export async function logout(): Promise { + const response = await fetch('/api/auth/logout', { method: 'POST' }) + if (!response.ok) { + const payload = (await response.json().catch(() => null)) as { error?: string } | null + const message = payload?.error || 'Logout failed' + throw new Error(message) + } +} diff --git a/frontends/nextjs/src/lib/auth/api/register.ts b/frontends/nextjs/src/lib/auth/api/register.ts new file mode 100644 index 000000000..f962b3b69 --- /dev/null +++ b/frontends/nextjs/src/lib/auth/api/register.ts @@ -0,0 +1,30 @@ +import type { User } from '@/lib/level-types' + +export async function register( + username: string, + email: string, + password: string, + options?: { tenantId?: string; profilePicture?: string; bio?: string } +): Promise { + const response = await fetch('/api/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + username, + email, + password, + tenantId: options?.tenantId, + profilePicture: options?.profilePicture, + bio: options?.bio, + }), + }) + + const payload = (await response.json().catch(() => null)) as { user?: User; error?: string } | null + + if (!response.ok || !payload?.user) { + const message = payload?.error || 'Registration failed' + throw new Error(message) + } + + return payload.user +} diff --git a/frontends/nextjs/src/lib/auth/clear-session-cookie.ts b/frontends/nextjs/src/lib/auth/clear-session-cookie.ts new file mode 100644 index 000000000..cc819d233 --- /dev/null +++ b/frontends/nextjs/src/lib/auth/clear-session-cookie.ts @@ -0,0 +1,12 @@ +import type { NextResponse } from 'next/server' +import { AUTH_COOKIE_NAME } from './session-constants' + +export function clearSessionCookie(response: NextResponse): void { + response.cookies.set(AUTH_COOKIE_NAME, '', { + httpOnly: true, + sameSite: 'lax', + secure: process.env.NODE_ENV === 'production', + path: '/', + maxAge: 0, + }) +} diff --git a/frontends/nextjs/src/lib/auth/session-constants.ts b/frontends/nextjs/src/lib/auth/session-constants.ts new file mode 100644 index 000000000..1780ecf80 --- /dev/null +++ b/frontends/nextjs/src/lib/auth/session-constants.ts @@ -0,0 +1,2 @@ +export const AUTH_COOKIE_NAME = 'mb_session' +export const DEFAULT_SESSION_TTL_MS = 1000 * 60 * 60 * 24 * 7 diff --git a/frontends/nextjs/src/lib/auth/set-session-cookie.ts b/frontends/nextjs/src/lib/auth/set-session-cookie.ts new file mode 100644 index 000000000..69c4ef7ba --- /dev/null +++ b/frontends/nextjs/src/lib/auth/set-session-cookie.ts @@ -0,0 +1,17 @@ +import type { NextResponse } from 'next/server' +import { AUTH_COOKIE_NAME, DEFAULT_SESSION_TTL_MS } from './session-constants' + +export function setSessionCookie( + response: NextResponse, + token: string, + options?: { maxAgeMs?: number } +): void { + const maxAgeMs = options?.maxAgeMs ?? DEFAULT_SESSION_TTL_MS + response.cookies.set(AUTH_COOKIE_NAME, token, { + httpOnly: true, + sameSite: 'lax', + secure: process.env.NODE_ENV === 'production', + path: '/', + maxAge: Math.floor(maxAgeMs / 1000), + }) +} diff --git a/frontends/nextjs/src/lib/db/auth/authenticate-user.ts b/frontends/nextjs/src/lib/db/auth/authenticate-user.ts new file mode 100644 index 000000000..69097472b --- /dev/null +++ b/frontends/nextjs/src/lib/db/auth/authenticate-user.ts @@ -0,0 +1,72 @@ +import { getAdapter } from '../dbal-client' +import { verifyPassword } from '../verify-password' +import type { User } from '../../types/level-types' + +export interface AuthenticateResult { + success: boolean + user: User | null + error?: 'invalid_credentials' | 'user_not_found' | 'account_locked' + requiresPasswordChange?: boolean +} + +/** + * Authenticate user by username and password. + * Returns user data on success, error code on failure. + * Uses DBAL adapter - never accesses Prisma directly. + */ +export const authenticateUser = async ( + username: string, + password: string +): Promise => { + const adapter = getAdapter() + + // Look up credentials + const credResult = await adapter.list('Credential', { + filter: { username }, + }) + + if (credResult.data.length === 0) { + return { success: false, user: null, error: 'invalid_credentials' } + } + + const credential = credResult.data[0] as { username: string; passwordHash: string } + const passwordValid = await verifyPassword(password, credential.passwordHash) + + if (!passwordValid) { + return { success: false, user: null, error: 'invalid_credentials' } + } + + // Fetch user data + const userResult = await adapter.list('User', { + filter: { username }, + }) + + if (userResult.data.length === 0) { + return { success: false, user: null, error: 'user_not_found' } + } + + const userData = userResult.data[0] as Record + + const user: User = { + id: String(userData.id), + username: String(userData.username), + email: String(userData.email), + role: userData.role as User['role'], + profilePicture: userData.profilePicture ? String(userData.profilePicture) : undefined, + bio: userData.bio ? String(userData.bio) : undefined, + createdAt: Number(userData.createdAt), + tenantId: userData.tenantId ? String(userData.tenantId) : undefined, + isInstanceOwner: Boolean(userData.isInstanceOwner), + } + + // Check first login flag + const firstLoginResult = await adapter.findFirst('GodCredentialExpiry', { + where: { settingKey: `first_login_${username}` }, + }) + + const requiresPasswordChange = firstLoginResult + ? (firstLoginResult as { value: string }).value === 'true' + : false + + return { success: true, user, requiresPasswordChange } +} diff --git a/frontends/nextjs/src/lib/db/auth/get-user-by-email.ts b/frontends/nextjs/src/lib/db/auth/get-user-by-email.ts new file mode 100644 index 000000000..c42bd2532 --- /dev/null +++ b/frontends/nextjs/src/lib/db/auth/get-user-by-email.ts @@ -0,0 +1,32 @@ +import { getAdapter } from '../dbal-client' +import type { User } from '../../types/level-types' + +/** + * Get user by email from DBAL. + * Single-responsibility lambda for email lookup. + */ +export const getUserByEmail = async (email: string): Promise => { + const adapter = getAdapter() + + const result = await adapter.list('User', { + filter: { email }, + }) + + if (result.data.length === 0) { + return null + } + + const userData = result.data[0] as Record + + return { + id: String(userData.id), + username: String(userData.username), + email: String(userData.email), + role: userData.role as User['role'], + profilePicture: userData.profilePicture ? String(userData.profilePicture) : undefined, + bio: userData.bio ? String(userData.bio) : undefined, + createdAt: Number(userData.createdAt), + tenantId: userData.tenantId ? String(userData.tenantId) : undefined, + isInstanceOwner: Boolean(userData.isInstanceOwner), + } +} diff --git a/frontends/nextjs/src/lib/db/auth/get-user-by-username.ts b/frontends/nextjs/src/lib/db/auth/get-user-by-username.ts new file mode 100644 index 000000000..3f03e6c83 --- /dev/null +++ b/frontends/nextjs/src/lib/db/auth/get-user-by-username.ts @@ -0,0 +1,38 @@ +import { getAdapter } from '../dbal-client' +import type { User } from '../../types/level-types' + +/** + * Get user by username from DBAL. + * Single-responsibility lambda for username lookup. + */ +export const getUserByUsername = async ( + username: string, + options?: { tenantId?: string } +): Promise => { + const adapter = getAdapter() + + const record = await adapter.findFirst('User', { + where: { + username, + ...(options?.tenantId ? { tenantId: options.tenantId } : {}), + }, + }) + + if (!record) { + return null + } + + const userData = record as Record + + return { + id: String(userData.id), + username: String(userData.username), + email: String(userData.email), + role: userData.role as User['role'], + profilePicture: userData.profilePicture ? String(userData.profilePicture) : undefined, + bio: userData.bio ? String(userData.bio) : undefined, + createdAt: Number(userData.createdAt), + tenantId: userData.tenantId ? String(userData.tenantId) : undefined, + isInstanceOwner: Boolean(userData.isInstanceOwner), + } +} diff --git a/frontends/nextjs/src/lib/db/auth/index.ts b/frontends/nextjs/src/lib/db/auth/index.ts new file mode 100644 index 000000000..187ee3fb1 --- /dev/null +++ b/frontends/nextjs/src/lib/db/auth/index.ts @@ -0,0 +1,4 @@ +export { authenticateUser } from './authenticate-user' +export type { AuthenticateResult } from './authenticate-user' +export { getUserByUsername } from './get-user-by-username' +export { getUserByEmail } from './get-user-by-email' diff --git a/frontends/nextjs/src/lib/db/dbal-client/delete-entity.ts b/frontends/nextjs/src/lib/db/dbal-client/delete-entity.ts index d5c9cb09e..7596824b2 100644 --- a/frontends/nextjs/src/lib/db/dbal-client/delete-entity.ts +++ b/frontends/nextjs/src/lib/db/dbal-client/delete-entity.ts @@ -1,9 +1,11 @@ import { getModel } from './get-model' +import { getPrimaryKeyField } from './get-primary-key-field' export async function deleteEntity(entity: string, id: string): Promise { const model = getModel(entity) + const primaryKeyField = getPrimaryKeyField(entity) try { - await model.delete({ where: { id } }) + await model.delete({ where: { [primaryKeyField]: id } }) return true } catch { return false diff --git a/frontends/nextjs/src/lib/db/dbal-client/get-primary-key-field.ts b/frontends/nextjs/src/lib/db/dbal-client/get-primary-key-field.ts new file mode 100644 index 000000000..de45a66f4 --- /dev/null +++ b/frontends/nextjs/src/lib/db/dbal-client/get-primary-key-field.ts @@ -0,0 +1,11 @@ +const PRIMARY_KEY_FIELDS: Record = { + Credential: 'username', + InstalledPackage: 'packageId', + PackageData: 'packageId', + PasswordResetToken: 'username', + SystemConfig: 'key', +} + +export function getPrimaryKeyField(entity: string): string { + return PRIMARY_KEY_FIELDS[entity] ?? 'id' +} diff --git a/frontends/nextjs/src/lib/db/dbal-client/primary-key-field.test.ts b/frontends/nextjs/src/lib/db/dbal-client/primary-key-field.test.ts new file mode 100644 index 000000000..fb93ddc84 --- /dev/null +++ b/frontends/nextjs/src/lib/db/dbal-client/primary-key-field.test.ts @@ -0,0 +1,85 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockGetModel = vi.fn() + +vi.mock('./get-model', () => ({ + getModel: mockGetModel, +})) + +import { getPrimaryKeyField } from './get-primary-key-field' +import { readEntity } from './read-entity' +import { updateEntity } from './update-entity' +import { deleteEntity } from './delete-entity' + +describe('getPrimaryKeyField', () => { + it.each([ + ['Credential', 'username'], + ['InstalledPackage', 'packageId'], + ['PackageData', 'packageId'], + ['PasswordResetToken', 'username'], + ['SystemConfig', 'key'], + ])('maps %s to %s', (entity, expected) => { + expect(getPrimaryKeyField(entity)).toBe(expected) + }) + + it('defaults to id for unknown entities', () => { + expect(getPrimaryKeyField('User')).toBe('id') + }) +}) + +describe('dbal-client primary key usage', () => { + const model = { + findUnique: vi.fn(), + update: vi.fn(), + delete: vi.fn(), + } + + beforeEach(() => { + model.findUnique.mockReset() + model.update.mockReset() + model.delete.mockReset() + mockGetModel.mockReset() + mockGetModel.mockReturnValue(model) + }) + + it('uses mapped key for readEntity', async () => { + model.findUnique.mockResolvedValue(null) + + await readEntity('InstalledPackage', 'forum-classic') + + expect(model.findUnique).toHaveBeenCalledWith({ + where: { packageId: 'forum-classic' }, + }) + }) + + it('uses mapped key for updateEntity', async () => { + model.update.mockResolvedValue({ username: 'admin' }) + + await updateEntity('Credential', 'admin', { passwordHash: 'hash' }) + + expect(model.update).toHaveBeenCalledWith({ + where: { username: 'admin' }, + data: { passwordHash: 'hash' }, + }) + }) + + it('uses mapped key for deleteEntity', async () => { + model.delete.mockResolvedValue({ key: 'theme' }) + + await deleteEntity('SystemConfig', 'theme') + + expect(model.delete).toHaveBeenCalledWith({ + where: { key: 'theme' }, + }) + }) + + it('falls back to id for standard entities', async () => { + model.findUnique.mockResolvedValue(null) + + await readEntity('User', 'user_123') + + expect(model.findUnique).toHaveBeenCalledWith({ + where: { id: 'user_123' }, + }) + }) +}) diff --git a/frontends/nextjs/src/lib/db/dbal-client/read-entity.ts b/frontends/nextjs/src/lib/db/dbal-client/read-entity.ts index 722401fdf..b82c66a8f 100644 --- a/frontends/nextjs/src/lib/db/dbal-client/read-entity.ts +++ b/frontends/nextjs/src/lib/db/dbal-client/read-entity.ts @@ -1,6 +1,8 @@ import { getModel } from './get-model' +import { getPrimaryKeyField } from './get-primary-key-field' export async function readEntity(entity: string, id: string): Promise { const model = getModel(entity) - return model.findUnique({ where: { id } }) + const primaryKeyField = getPrimaryKeyField(entity) + return model.findUnique({ where: { [primaryKeyField]: id } }) } diff --git a/frontends/nextjs/src/lib/db/dbal-client/update-entity.ts b/frontends/nextjs/src/lib/db/dbal-client/update-entity.ts index 11b5c7361..b2e267e1f 100644 --- a/frontends/nextjs/src/lib/db/dbal-client/update-entity.ts +++ b/frontends/nextjs/src/lib/db/dbal-client/update-entity.ts @@ -1,4 +1,5 @@ import { getModel } from './get-model' +import { getPrimaryKeyField } from './get-primary-key-field' export async function updateEntity( entity: string, @@ -6,6 +7,7 @@ export async function updateEntity( data: Record ): Promise { const model = getModel(entity) + const primaryKeyField = getPrimaryKeyField(entity) // Filter out undefined values const cleanData: Record = {} for (const [key, value] of Object.entries(data)) { @@ -13,5 +15,5 @@ export async function updateEntity( cleanData[key] = value } } - return model.update({ where: { id }, data: cleanData }) + return model.update({ where: { [primaryKeyField]: id }, data: cleanData }) } diff --git a/frontends/nextjs/src/lib/db/index.ts b/frontends/nextjs/src/lib/db/index.ts index a9ffe30b9..6d34c90b5 100644 --- a/frontends/nextjs/src/lib/db/index.ts +++ b/frontends/nextjs/src/lib/db/index.ts @@ -12,8 +12,10 @@ export { verifyPassword } from './verify-password' export { initializeDatabase } from './initialize-database' // Domain re-exports +export * from './auth' export * from './users' export * from './credentials' +export * from './sessions' export * from './workflows' export * from './lua-scripts' export * from './pages' @@ -35,8 +37,10 @@ export * from './database-admin' import { initializeDatabase } from './initialize-database' import { hashPassword } from './hash-password' import { verifyPassword } from './verify-password' +import * as auth from './auth' import * as users from './users' import * as credentials from './credentials' +import * as sessions from './sessions' import * as workflows from './workflows' import * as luaScripts from './lua-scripts' import * as pages from './pages' @@ -64,6 +68,11 @@ export class Database { static hashPassword = hashPassword static verifyPassword = verifyPassword + // Auth + static authenticateUser = auth.authenticateUser + static getUserByUsername = auth.getUserByUsername + static getUserByEmail = auth.getUserByEmail + // Users static getUsers = users.getUsers static getUserById = users.getUserById @@ -83,6 +92,15 @@ export class Database { static getPasswordResetTokens = credentials.getPasswordResetTokens static setPasswordResetToken = credentials.setPasswordResetToken static deletePasswordResetToken = credentials.deletePasswordResetToken + + // Sessions + static createSession = sessions.createSession + static getSessionById = sessions.getSessionById + static getSessionByToken = sessions.getSessionByToken + static updateSession = sessions.updateSession + static deleteSession = sessions.deleteSession + static deleteSessionByToken = sessions.deleteSessionByToken + static listSessions = sessions.listSessions // Workflows static getWorkflows = workflows.getWorkflows diff --git a/frontends/nextjs/src/lib/db/sessions/create-session.test.ts b/frontends/nextjs/src/lib/db/sessions/create-session.test.ts new file mode 100644 index 000000000..11ece4d60 --- /dev/null +++ b/frontends/nextjs/src/lib/db/sessions/create-session.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +const mockCreate = vi.fn() +const mockAdapter = { create: mockCreate } + +vi.mock('../dbal-client', () => ({ + getAdapter: () => mockAdapter, +})) + +import { createSession } from './create-session' + +describe('createSession', () => { + beforeEach(() => { + mockCreate.mockReset() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('creates a session record with explicit values', async () => { + const now = 1700000000000 + vi.spyOn(Date, 'now').mockReturnValue(now) + + const result = await createSession({ + id: 'session_1', + token: 'token_1', + userId: 'user_1', + expiresAt: 1700000100000, + createdAt: 1699990000000, + lastActivity: 1699990001000, + }) + + expect(mockCreate).toHaveBeenCalledWith('Session', { + id: 'session_1', + userId: 'user_1', + token: 'token_1', + expiresAt: BigInt(1700000100000), + createdAt: BigInt(1699990000000), + lastActivity: BigInt(1699990001000), + }) + + expect(result).toEqual({ + id: 'session_1', + userId: 'user_1', + token: 'token_1', + expiresAt: 1700000100000, + createdAt: 1699990000000, + lastActivity: 1699990001000, + }) + }) +}) diff --git a/frontends/nextjs/src/lib/db/sessions/create-session.ts b/frontends/nextjs/src/lib/db/sessions/create-session.ts new file mode 100644 index 000000000..76e390312 --- /dev/null +++ b/frontends/nextjs/src/lib/db/sessions/create-session.ts @@ -0,0 +1,31 @@ +import { randomBytes, randomUUID } from 'crypto' +import { getAdapter } from '../dbal-client' +import type { CreateSessionInput, Session } from './types' + +const TOKEN_BYTES = 32 + +export async function createSession(input: CreateSessionInput): Promise { + const adapter = getAdapter() + const createdAt = input.createdAt ?? Date.now() + const lastActivity = input.lastActivity ?? createdAt + const sessionId = input.id ?? randomUUID() + const token = input.token ?? randomBytes(TOKEN_BYTES).toString('hex') + + await adapter.create('Session', { + id: sessionId, + userId: input.userId, + token, + expiresAt: BigInt(input.expiresAt), + createdAt: BigInt(createdAt), + lastActivity: BigInt(lastActivity), + }) + + return { + id: sessionId, + userId: input.userId, + token, + expiresAt: input.expiresAt, + createdAt, + lastActivity, + } +} diff --git a/frontends/nextjs/src/lib/db/sessions/delete-session-by-token.test.ts b/frontends/nextjs/src/lib/db/sessions/delete-session-by-token.test.ts new file mode 100644 index 000000000..5468e2615 --- /dev/null +++ b/frontends/nextjs/src/lib/db/sessions/delete-session-by-token.test.ts @@ -0,0 +1,37 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const mockList = vi.fn() +const mockDelete = vi.fn() +const mockAdapter = { list: mockList, delete: mockDelete } + +vi.mock('../dbal-client', () => ({ + getAdapter: () => mockAdapter, +})) + +import { deleteSessionByToken } from './delete-session-by-token' + +describe('deleteSessionByToken', () => { + beforeEach(() => { + mockList.mockReset() + mockDelete.mockReset() + }) + + it('returns false when no session exists', async () => { + mockList.mockResolvedValue({ data: [] }) + + const result = await deleteSessionByToken('missing') + + expect(mockList).toHaveBeenCalledWith('Session', { filter: { token: 'missing' } }) + expect(result).toBe(false) + }) + + it('deletes session when token exists', async () => { + mockList.mockResolvedValue({ data: [{ id: 'session_1' }] }) + mockDelete.mockResolvedValue(true) + + const result = await deleteSessionByToken('token') + + expect(mockDelete).toHaveBeenCalledWith('Session', 'session_1') + expect(result).toBe(true) + }) +}) diff --git a/frontends/nextjs/src/lib/db/sessions/delete-session-by-token.ts b/frontends/nextjs/src/lib/db/sessions/delete-session-by-token.ts new file mode 100644 index 000000000..615b737fa --- /dev/null +++ b/frontends/nextjs/src/lib/db/sessions/delete-session-by-token.ts @@ -0,0 +1,10 @@ +import { getAdapter } from '../dbal-client' + +export async function deleteSessionByToken(token: string): Promise { + const adapter = getAdapter() + const result = await adapter.list('Session', { filter: { token } }) + if (!result.data.length) return false + const session = result.data[0] as any + await adapter.delete('Session', session.id) + return true +} diff --git a/frontends/nextjs/src/lib/db/sessions/delete-session.test.ts b/frontends/nextjs/src/lib/db/sessions/delete-session.test.ts new file mode 100644 index 000000000..2b12d2b4a --- /dev/null +++ b/frontends/nextjs/src/lib/db/sessions/delete-session.test.ts @@ -0,0 +1,25 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const mockDelete = vi.fn() +const mockAdapter = { delete: mockDelete } + +vi.mock('../dbal-client', () => ({ + getAdapter: () => mockAdapter, +})) + +import { deleteSession } from './delete-session' + +describe('deleteSession', () => { + beforeEach(() => { + mockDelete.mockReset() + }) + + it('deletes session by id', async () => { + mockDelete.mockResolvedValue(true) + + const result = await deleteSession('session_1') + + expect(mockDelete).toHaveBeenCalledWith('Session', 'session_1') + expect(result).toBe(true) + }) +}) diff --git a/frontends/nextjs/src/lib/db/sessions/delete-session.ts b/frontends/nextjs/src/lib/db/sessions/delete-session.ts new file mode 100644 index 000000000..c10497845 --- /dev/null +++ b/frontends/nextjs/src/lib/db/sessions/delete-session.ts @@ -0,0 +1,6 @@ +import { getAdapter } from '../dbal-client' + +export async function deleteSession(sessionId: string): Promise { + const adapter = getAdapter() + return await adapter.delete('Session', sessionId) +} diff --git a/frontends/nextjs/src/lib/db/sessions/get-session-by-id.ts b/frontends/nextjs/src/lib/db/sessions/get-session-by-id.ts new file mode 100644 index 000000000..ee28f93c6 --- /dev/null +++ b/frontends/nextjs/src/lib/db/sessions/get-session-by-id.ts @@ -0,0 +1,10 @@ +import { getAdapter } from '../dbal-client' +import { mapSessionRecord } from './map-session-record' +import type { Session } from './types' + +export async function getSessionById(sessionId: string): Promise { + const adapter = getAdapter() + const record = await adapter.read('Session', sessionId) + if (!record) return null + return mapSessionRecord(record) +} diff --git a/frontends/nextjs/src/lib/db/sessions/get-session-by-token.test.ts b/frontends/nextjs/src/lib/db/sessions/get-session-by-token.test.ts new file mode 100644 index 000000000..a7d2cc82c --- /dev/null +++ b/frontends/nextjs/src/lib/db/sessions/get-session-by-token.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +const mockList = vi.fn() +const mockDelete = vi.fn() +const mockAdapter = { list: mockList, delete: mockDelete } + +vi.mock('../dbal-client', () => ({ + getAdapter: () => mockAdapter, +})) + +import { getSessionByToken } from './get-session-by-token' + +describe('getSessionByToken', () => { + beforeEach(() => { + mockList.mockReset() + mockDelete.mockReset() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('returns null when no session exists', async () => { + mockList.mockResolvedValue({ data: [] }) + + const result = await getSessionByToken('token') + + expect(mockList).toHaveBeenCalledWith('Session', { filter: { token: 'token' } }) + expect(result).toBeNull() + }) + + it('returns session when not expired', async () => { + vi.spyOn(Date, 'now').mockReturnValue(1000) + mockList.mockResolvedValue({ + data: [ + { + id: 'session_1', + userId: 'user_1', + token: 'token', + expiresAt: BigInt(2000), + createdAt: BigInt(500), + lastActivity: BigInt(900), + }, + ], + }) + + const result = await getSessionByToken('token') + + expect(result).toEqual({ + id: 'session_1', + userId: 'user_1', + token: 'token', + expiresAt: 2000, + createdAt: 500, + lastActivity: 900, + }) + expect(mockDelete).not.toHaveBeenCalled() + }) + + it('deletes and returns null when expired', async () => { + vi.spyOn(Date, 'now').mockReturnValue(3000) + mockList.mockResolvedValue({ + data: [ + { + id: 'session_2', + userId: 'user_2', + token: 'expired', + expiresAt: BigInt(2000), + createdAt: BigInt(1000), + lastActivity: BigInt(1500), + }, + ], + }) + + const result = await getSessionByToken('expired') + + expect(result).toBeNull() + expect(mockDelete).toHaveBeenCalledWith('Session', 'session_2') + }) +}) diff --git a/frontends/nextjs/src/lib/db/sessions/get-session-by-token.ts b/frontends/nextjs/src/lib/db/sessions/get-session-by-token.ts new file mode 100644 index 000000000..00f02f74c --- /dev/null +++ b/frontends/nextjs/src/lib/db/sessions/get-session-by-token.ts @@ -0,0 +1,18 @@ +import { getAdapter } from '../dbal-client' +import { deleteSession } from './delete-session' +import { mapSessionRecord } from './map-session-record' +import type { Session } from './types' + +export async function getSessionByToken(token: string): Promise { + const adapter = getAdapter() + const result = await adapter.list('Session', { filter: { token } }) + if (!result.data.length) return null + + const session = mapSessionRecord(result.data[0]) + if (session.expiresAt <= Date.now()) { + await deleteSession(session.id) + return null + } + + return session +} diff --git a/frontends/nextjs/src/lib/db/sessions/index.ts b/frontends/nextjs/src/lib/db/sessions/index.ts new file mode 100644 index 000000000..b2703fbaa --- /dev/null +++ b/frontends/nextjs/src/lib/db/sessions/index.ts @@ -0,0 +1,8 @@ +export { createSession } from './create-session' +export { getSessionById } from './get-session-by-id' +export { getSessionByToken } from './get-session-by-token' +export { updateSession } from './update-session' +export { deleteSession } from './delete-session' +export { deleteSessionByToken } from './delete-session-by-token' +export { listSessions } from './list-sessions' +export type { Session, CreateSessionInput, UpdateSessionInput, ListSessionsOptions } from './types' diff --git a/frontends/nextjs/src/lib/db/sessions/list-sessions.test.ts b/frontends/nextjs/src/lib/db/sessions/list-sessions.test.ts new file mode 100644 index 000000000..c7e360d95 --- /dev/null +++ b/frontends/nextjs/src/lib/db/sessions/list-sessions.test.ts @@ -0,0 +1,88 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +const mockList = vi.fn() +const mockAdapter = { list: mockList } + +vi.mock('../dbal-client', () => ({ + getAdapter: () => mockAdapter, +})) + +import { listSessions } from './list-sessions' + +describe('listSessions', () => { + beforeEach(() => { + mockList.mockReset() + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('filters out expired sessions by default', async () => { + vi.spyOn(Date, 'now').mockReturnValue(2000) + mockList.mockResolvedValue({ + data: [ + { + id: 'session_active', + userId: 'user_1', + token: 'active', + expiresAt: BigInt(3000), + createdAt: BigInt(1000), + lastActivity: BigInt(1500), + }, + { + id: 'session_expired', + userId: 'user_1', + token: 'expired', + expiresAt: BigInt(1500), + createdAt: BigInt(900), + lastActivity: BigInt(1200), + }, + ], + }) + + const result = await listSessions({ userId: 'user_1' }) + + expect(mockList).toHaveBeenCalledWith('Session', { filter: { userId: 'user_1' } }) + expect(result).toEqual([ + { + id: 'session_active', + userId: 'user_1', + token: 'active', + expiresAt: 3000, + createdAt: 1000, + lastActivity: 1500, + }, + ]) + }) + + it('returns expired sessions when includeExpired is true', async () => { + vi.spyOn(Date, 'now').mockReturnValue(2000) + mockList.mockResolvedValue({ + data: [ + { + id: 'session_expired', + userId: 'user_2', + token: 'expired', + expiresAt: BigInt(1500), + createdAt: BigInt(900), + lastActivity: BigInt(1200), + }, + ], + }) + + const result = await listSessions({ includeExpired: true }) + + expect(mockList).toHaveBeenCalledWith('Session') + expect(result).toEqual([ + { + id: 'session_expired', + userId: 'user_2', + token: 'expired', + expiresAt: 1500, + createdAt: 900, + lastActivity: 1200, + }, + ]) + }) +}) diff --git a/frontends/nextjs/src/lib/db/sessions/list-sessions.ts b/frontends/nextjs/src/lib/db/sessions/list-sessions.ts new file mode 100644 index 000000000..4a52f0265 --- /dev/null +++ b/frontends/nextjs/src/lib/db/sessions/list-sessions.ts @@ -0,0 +1,19 @@ +import { getAdapter } from '../dbal-client' +import { mapSessionRecord } from './map-session-record' +import type { ListSessionsOptions, Session } from './types' + +export async function listSessions(options?: ListSessionsOptions): Promise { + const adapter = getAdapter() + const result = options?.userId + ? await adapter.list('Session', { filter: { userId: options.userId } }) + : await adapter.list('Session') + + const sessions = result.data.map(mapSessionRecord) + + if (options?.includeExpired) { + return sessions + } + + const now = Date.now() + return sessions.filter((session) => session.expiresAt > now) +} diff --git a/frontends/nextjs/src/lib/db/sessions/map-session-record.ts b/frontends/nextjs/src/lib/db/sessions/map-session-record.ts new file mode 100644 index 000000000..1c59a30c6 --- /dev/null +++ b/frontends/nextjs/src/lib/db/sessions/map-session-record.ts @@ -0,0 +1,12 @@ +import type { Session } from './types' + +export function mapSessionRecord(record: any): Session { + return { + id: record.id, + userId: record.userId, + token: record.token, + expiresAt: Number(record.expiresAt), + createdAt: Number(record.createdAt), + lastActivity: Number(record.lastActivity), + } +} diff --git a/frontends/nextjs/src/lib/db/sessions/types.ts b/frontends/nextjs/src/lib/db/sessions/types.ts new file mode 100644 index 000000000..d6820f432 --- /dev/null +++ b/frontends/nextjs/src/lib/db/sessions/types.ts @@ -0,0 +1,28 @@ +export interface Session { + id: string + userId: string + token: string + expiresAt: number + createdAt: number + lastActivity: number +} + +export interface CreateSessionInput { + userId: string + expiresAt: number + id?: string + token?: string + createdAt?: number + lastActivity?: number +} + +export interface UpdateSessionInput { + token?: string + expiresAt?: number + lastActivity?: number +} + +export interface ListSessionsOptions { + userId?: string + includeExpired?: boolean +} diff --git a/frontends/nextjs/src/lib/db/sessions/update-session.test.ts b/frontends/nextjs/src/lib/db/sessions/update-session.test.ts new file mode 100644 index 000000000..018887728 --- /dev/null +++ b/frontends/nextjs/src/lib/db/sessions/update-session.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const mockUpdate = vi.fn() +const mockAdapter = { update: mockUpdate } + +vi.mock('../dbal-client', () => ({ + getAdapter: () => mockAdapter, +})) + +import { updateSession } from './update-session' + +describe('updateSession', () => { + beforeEach(() => { + mockUpdate.mockReset() + }) + + it('updates session fields and maps result', async () => { + mockUpdate.mockResolvedValue({ + id: 'session_1', + userId: 'user_1', + token: 'token', + expiresAt: BigInt(5000), + createdAt: BigInt(1000), + lastActivity: BigInt(4000), + }) + + const result = await updateSession('session_1', { + expiresAt: 5000, + lastActivity: 4000, + }) + + expect(mockUpdate).toHaveBeenCalledWith('Session', 'session_1', { + expiresAt: BigInt(5000), + lastActivity: BigInt(4000), + }) + + expect(result).toEqual({ + id: 'session_1', + userId: 'user_1', + token: 'token', + expiresAt: 5000, + createdAt: 1000, + lastActivity: 4000, + }) + }) +}) diff --git a/frontends/nextjs/src/lib/db/sessions/update-session.ts b/frontends/nextjs/src/lib/db/sessions/update-session.ts new file mode 100644 index 000000000..3ca8b11a8 --- /dev/null +++ b/frontends/nextjs/src/lib/db/sessions/update-session.ts @@ -0,0 +1,13 @@ +import { getAdapter } from '../dbal-client' +import type { Session, UpdateSessionInput } from './types' +import { mapSessionRecord } from './map-session-record' + +export async function updateSession(sessionId: string, input: UpdateSessionInput): Promise { + const adapter = getAdapter() + const record = await adapter.update('Session', sessionId, { + ...(input.token ? { token: input.token } : {}), + ...(input.expiresAt !== undefined ? { expiresAt: BigInt(input.expiresAt) } : {}), + ...(input.lastActivity !== undefined ? { lastActivity: BigInt(input.lastActivity) } : {}), + }) + return mapSessionRecord(record) +} diff --git a/frontends/nextjs/src/lib/db/users/get-user-by-email.test.ts b/frontends/nextjs/src/lib/db/users/get-user-by-email.test.ts new file mode 100644 index 000000000..b47914b33 --- /dev/null +++ b/frontends/nextjs/src/lib/db/users/get-user-by-email.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const mockFindFirst = vi.fn() +const mockAdapter = { findFirst: mockFindFirst } + +vi.mock('../dbal-client', () => ({ + getAdapter: () => mockAdapter, +})) + +import { getUserByEmail } from './get-user-by-email' + +describe('getUserByEmail', () => { + beforeEach(() => { + mockFindFirst.mockReset() + }) + + it('returns null when user not found', async () => { + mockFindFirst.mockResolvedValue(null) + + const result = await getUserByEmail('missing@example.com') + + expect(mockFindFirst).toHaveBeenCalledWith('User', { where: { email: 'missing@example.com' } }) + expect(result).toBeNull() + }) + + it('returns user when found', async () => { + mockFindFirst.mockResolvedValue({ + id: 'user_2', + username: 'bob', + email: 'bob@example.com', + role: 'user', + profilePicture: 'pic.png', + bio: null, + createdAt: BigInt(2000), + tenantId: 'tenant_2', + isInstanceOwner: true, + }) + + const result = await getUserByEmail('bob@example.com') + + expect(result).toEqual({ + id: 'user_2', + username: 'bob', + email: 'bob@example.com', + role: 'user', + profilePicture: 'pic.png', + bio: undefined, + createdAt: 2000, + tenantId: 'tenant_2', + isInstanceOwner: true, + }) + }) + + it('includes tenant filter when provided', async () => { + mockFindFirst.mockResolvedValue(null) + + await getUserByEmail('bob@example.com', { tenantId: 'tenant_2' }) + + expect(mockFindFirst).toHaveBeenCalledWith('User', { + where: { email: 'bob@example.com', tenantId: 'tenant_2' }, + }) + }) +}) diff --git a/frontends/nextjs/src/lib/db/users/get-user-by-email.ts b/frontends/nextjs/src/lib/db/users/get-user-by-email.ts new file mode 100644 index 000000000..fe7a8d6a7 --- /dev/null +++ b/frontends/nextjs/src/lib/db/users/get-user-by-email.ts @@ -0,0 +1,30 @@ +import { getAdapter } from '../dbal-client' +import type { User } from '../../types/level-types' + +export async function getUserByEmail( + email: string, + options?: { tenantId?: string } +): Promise { + const adapter = getAdapter() + const record = await adapter.findFirst('User', { + where: { + email, + ...(options?.tenantId ? { tenantId: options.tenantId } : {}), + }, + }) + + if (!record) return null + + const user = record as any + return { + id: user.id, + username: user.username, + email: user.email, + role: user.role as any, + profilePicture: user.profilePicture || undefined, + bio: user.bio || undefined, + createdAt: Number(user.createdAt), + tenantId: user.tenantId || undefined, + isInstanceOwner: user.isInstanceOwner, + } +} diff --git a/frontends/nextjs/src/lib/db/users/get-user-by-username.test.ts b/frontends/nextjs/src/lib/db/users/get-user-by-username.test.ts new file mode 100644 index 000000000..ca0bf022d --- /dev/null +++ b/frontends/nextjs/src/lib/db/users/get-user-by-username.test.ts @@ -0,0 +1,63 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const mockFindFirst = vi.fn() +const mockAdapter = { findFirst: mockFindFirst } + +vi.mock('../dbal-client', () => ({ + getAdapter: () => mockAdapter, +})) + +import { getUserByUsername } from './get-user-by-username' + +describe('getUserByUsername', () => { + beforeEach(() => { + mockFindFirst.mockReset() + }) + + it('returns null when user not found', async () => { + mockFindFirst.mockResolvedValue(null) + + const result = await getUserByUsername('missing') + + expect(mockFindFirst).toHaveBeenCalledWith('User', { where: { username: 'missing' } }) + expect(result).toBeNull() + }) + + it('returns user when found', async () => { + mockFindFirst.mockResolvedValue({ + id: 'user_1', + username: 'alice', + email: 'alice@example.com', + role: 'admin', + profilePicture: null, + bio: 'Bio', + createdAt: BigInt(1000), + tenantId: null, + isInstanceOwner: false, + }) + + const result = await getUserByUsername('alice') + + expect(result).toEqual({ + id: 'user_1', + username: 'alice', + email: 'alice@example.com', + role: 'admin', + profilePicture: undefined, + bio: 'Bio', + createdAt: 1000, + tenantId: undefined, + isInstanceOwner: false, + }) + }) + + it('includes tenant filter when provided', async () => { + mockFindFirst.mockResolvedValue(null) + + await getUserByUsername('alice', { tenantId: 'tenant_1' }) + + expect(mockFindFirst).toHaveBeenCalledWith('User', { + where: { username: 'alice', tenantId: 'tenant_1' }, + }) + }) +}) diff --git a/frontends/nextjs/src/lib/db/users/get-user-by-username.ts b/frontends/nextjs/src/lib/db/users/get-user-by-username.ts new file mode 100644 index 000000000..f9b1aae0e --- /dev/null +++ b/frontends/nextjs/src/lib/db/users/get-user-by-username.ts @@ -0,0 +1,30 @@ +import { getAdapter } from '../dbal-client' +import type { User } from '../../types/level-types' + +export async function getUserByUsername( + username: string, + options?: { tenantId?: string } +): Promise { + const adapter = getAdapter() + const record = await adapter.findFirst('User', { + where: { + username, + ...(options?.tenantId ? { tenantId: options.tenantId } : {}), + }, + }) + + if (!record) return null + + const user = record as any + return { + id: user.id, + username: user.username, + email: user.email, + role: user.role as any, + profilePicture: user.profilePicture || undefined, + bio: user.bio || undefined, + createdAt: Number(user.createdAt), + tenantId: user.tenantId || undefined, + isInstanceOwner: user.isInstanceOwner, + } +} diff --git a/frontends/nextjs/src/lib/db/users/index.ts b/frontends/nextjs/src/lib/db/users/index.ts index e2196b338..6ea931c65 100644 --- a/frontends/nextjs/src/lib/db/users/index.ts +++ b/frontends/nextjs/src/lib/db/users/index.ts @@ -1,5 +1,7 @@ export { getUsers } from './get-users' export { getUserById } from './get-user-by-id' +export { getUserByUsername } from './get-user-by-username' +export { getUserByEmail } from './get-user-by-email' export { setUsers } from './set-users' export { addUser } from './add-user' export { updateUser } from './update-user' diff --git a/frontends/nextjs/src/lib/github/parse-workflow-run-logs-options.test.ts b/frontends/nextjs/src/lib/github/parse-workflow-run-logs-options.test.ts new file mode 100644 index 000000000..36eb224af --- /dev/null +++ b/frontends/nextjs/src/lib/github/parse-workflow-run-logs-options.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import { parseWorkflowRunLogsOptions } from './parse-workflow-run-logs-options' + +describe('parseWorkflowRunLogsOptions', () => { + it('defaults when params are empty', () => { + const params = new URLSearchParams() + const result = parseWorkflowRunLogsOptions(params) + + expect(result).toEqual({ + runName: 'Workflow Run', + includeLogs: true, + jobLimit: undefined, + }) + }) + + it('parses includeLogs false values', () => { + const params = new URLSearchParams({ + includeLogs: 'false', + }) + const result = parseWorkflowRunLogsOptions(params) + + expect(result.includeLogs).toBe(false) + }) + + it('clamps jobLimit within bounds', () => { + const params = new URLSearchParams({ + jobLimit: '250', + }) + const result = parseWorkflowRunLogsOptions(params) + + expect(result.jobLimit).toBe(100) + }) + + it('trims runName and preserves includeLogs true', () => { + const params = new URLSearchParams({ + runName: ' Deploy Pipeline ', + includeLogs: '1', + jobLimit: '5', + }) + const result = parseWorkflowRunLogsOptions(params) + + expect(result).toEqual({ + runName: 'Deploy Pipeline', + includeLogs: true, + jobLimit: 5, + }) + }) +}) diff --git a/frontends/nextjs/src/lib/github/parse-workflow-run-logs-options.ts b/frontends/nextjs/src/lib/github/parse-workflow-run-logs-options.ts new file mode 100644 index 000000000..86250e352 --- /dev/null +++ b/frontends/nextjs/src/lib/github/parse-workflow-run-logs-options.ts @@ -0,0 +1,26 @@ +export type WorkflowRunLogsOptions = { + runName: string + includeLogs: boolean + jobLimit?: number +} + +export const parseWorkflowRunLogsOptions = ( + params: URLSearchParams +): WorkflowRunLogsOptions => { + const runName = params.get('runName')?.trim() || 'Workflow Run' + const includeLogsParam = params.get('includeLogs')?.trim().toLowerCase() + const includeLogs = includeLogsParam + ? !['false', '0', 'no'].includes(includeLogsParam) + : true + const jobLimitParam = params.get('jobLimit') + const parsedJobLimit = jobLimitParam ? Number(jobLimitParam) : Number.NaN + const jobLimit = Number.isFinite(parsedJobLimit) + ? Math.max(1, Math.min(100, Math.floor(parsedJobLimit))) + : undefined + + return { + runName, + includeLogs, + jobLimit, + } +} diff --git a/packages/spark-tools/package.json b/packages/spark-tools/package.json index 64c3d7249..0d7430a3c 100644 --- a/packages/spark-tools/package.json +++ b/packages/spark-tools/package.json @@ -80,25 +80,25 @@ "package.json" ], "devDependencies": { - "@rollup/plugin-commonjs": "^28.0.3", + "@rollup/plugin-commonjs": "^29.0.0", "@rollup/plugin-json": "^6.1.0", - "@rollup/plugin-node-resolve": "^16.0.0", - "@rollup/plugin-replace": "^6.0.2", + "@rollup/plugin-node-resolve": "^16.0.3", + "@rollup/plugin-replace": "^6.0.3", "@rollup/plugin-terser": "^0.4.4", - "@rollup/plugin-typescript": "^12.1.2", - "@testing-library/jest-dom": "^6.6.3", - "@testing-library/react": "^16.0.1", + "@rollup/plugin-typescript": "^12.3.0", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.1", "@types/body-parser": "^1.19.6", - "@types/express": "^5.0.1", - "@types/node": "^22.13.9", - "@types/react": "^19.0.0", - "jsdom": "^25.0.1", - "rollup": "^4.35.0", - "rollup-plugin-delete": "^3.0.1", + "@types/express": "^5.0.6", + "@types/node": "^25.0.3", + "@types/react": "^19.2.7", + "jsdom": "^27.3.0", + "rollup": "^4.54.0", + "rollup-plugin-delete": "^3.0.2", "tslib": "^2.8.1", - "ulid": "^3.0.0", - "vitest": "^3.0.9", - "zod": "^3.24.2" + "ulid": "^3.0.2", + "vitest": "^4.0.16", + "zod": "^4.2.1" }, "peerDependencies": { "react": "^19.0.0", @@ -112,8 +112,8 @@ "access": "public" }, "dependencies": { - "body-parser": "^1.20.3", - "express": "^5.2.0", - "octokit": "^5.0.3" + "body-parser": "^2.2.1", + "express": "^5.2.1", + "octokit": "^5.0.5" } } diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 943a91149..3550f2096 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -31,6 +31,19 @@ model Credential { passwordHash String } +model Session { + id String @id + userId String + token String @unique + expiresAt BigInt + createdAt BigInt + lastActivity BigInt + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@index([expiresAt]) +} + model Workflow { id String @id name String