docs: nextjs,frontends,session (86 files)

This commit is contained in:
2025-12-26 00:03:46 +00:00
parent 8fa6de14c0
commit 115b679983
86 changed files with 4085 additions and 1908 deletions

View File

@@ -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
# ============================================================================

View File

@@ -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"

View File

@@ -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"

View File

@@ -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<CreateUserInput> users = {...};
auto result = client.batchCreateUsers(users);
auto created = client.batchCreateUsers(users);
std::vector<UpdateUserBatchItem> updates = {...};
auto updated = client.batchUpdateUsers(updates);
std::vector<std::string> ids = {...};
auto deleted = client.batchDeleteUsers(ids);
```
Package equivalents are available via `batchCreatePackages`, `batchUpdatePackages`, and `batchDeletePackages`.
## Security Hardening
### 1. Run as Non-Root

View File

@@ -32,6 +32,9 @@ public:
Result<User> updateUser(const std::string& id, const UpdateUserInput& input);
Result<bool> deleteUser(const std::string& id);
Result<std::vector<User>> listUsers(const ListOptions& options);
Result<int> batchCreateUsers(const std::vector<CreateUserInput>& inputs);
Result<int> batchUpdateUsers(const std::vector<UpdateUserBatchItem>& updates);
Result<int> batchDeleteUsers(const std::vector<std::string>& ids);
Result<PageView> createPage(const CreatePageInput& input);
Result<PageView> getPage(const std::string& id);
@@ -63,6 +66,9 @@ public:
Result<Package> updatePackage(const std::string& id, const UpdatePackageInput& input);
Result<bool> deletePackage(const std::string& id);
Result<std::vector<Package>> listPackages(const ListOptions& options);
Result<int> batchCreatePackages(const std::vector<CreatePackageInput>& inputs);
Result<int> batchUpdatePackages(const std::vector<UpdatePackageBatchItem>& updates);
Result<int> batchDeletePackages(const std::vector<std::string>& ids);
void close();

View File

@@ -40,6 +40,11 @@ struct UpdateUserInput {
std::optional<UserRole> role;
};
struct UpdateUserBatchItem {
std::string id;
UpdateUserInput data;
};
struct PageView {
std::string id;
std::string slug;
@@ -194,6 +199,11 @@ struct UpdatePackageInput {
std::optional<std::string> installed_by;
};
struct UpdatePackageBatchItem {
std::string id;
UpdatePackageInput data;
};
struct ListOptions {
std::map<std::string, std::string> filter;
std::map<std::string, std::string> sort;

View File

@@ -259,6 +259,61 @@ Result<std::vector<User>> Client::listUsers(const ListOptions& options) {
return Result<std::vector<User>>(std::vector<User>());
}
Result<int> Client::batchCreateUsers(const std::vector<CreateUserInput>& inputs) {
if (inputs.empty()) {
return Result<int>(0);
}
std::vector<std::string> 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<int>(static_cast<int>(created_ids.size()));
}
Result<int> Client::batchUpdateUsers(const std::vector<UpdateUserBatchItem>& updates) {
if (updates.empty()) {
return Result<int>(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<int>(updated);
}
Result<int> Client::batchDeleteUsers(const std::vector<std::string>& ids) {
if (ids.empty()) {
return Result<int>(0);
}
int deleted = 0;
for (const auto& id : ids) {
auto result = deleteUser(id);
if (result.isError()) {
return result.error();
}
deleted++;
}
return Result<int>(deleted);
}
Result<PageView> Client::createPage(const CreatePageInput& input) {
// Validation
if (!isValidSlug(input.slug)) {
@@ -670,6 +725,13 @@ Result<Session> 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<Session>(it->second);
}
@@ -741,6 +803,24 @@ Result<bool> Client::deleteSession(const std::string& id) {
Result<std::vector<Session>> Client::listSessions(const ListOptions& options) {
auto& store = getStore();
auto now = std::chrono::system_clock::now();
std::vector<std::string> 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<Session> sessions;
for (const auto& [id, session] : store.sessions) {
@@ -1159,6 +1239,65 @@ Result<std::vector<Package>> Client::listPackages(const ListOptions& options) {
return Result<std::vector<Package>>(std::vector<Package>());
}
Result<int> Client::batchCreatePackages(const std::vector<CreatePackageInput>& inputs) {
if (inputs.empty()) {
return Result<int>(0);
}
std::vector<std::string> 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<int>(static_cast<int>(created_ids.size()));
}
Result<int> Client::batchUpdatePackages(const std::vector<UpdatePackageBatchItem>& updates) {
if (updates.empty()) {
return Result<int>(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<int>(updated);
}
Result<int> Client::batchDeletePackages(const std::vector<std::string>& ids) {
if (ids.empty()) {
return Result<int>(0);
}
int deleted = 0;
for (const auto& id : ids) {
auto result = deletePackage(id);
if (result.isError()) {
return result.error();
}
deleted++;
}
return Result<int>(deleted);
}
void Client::close() {
// For in-memory implementation, optionally clear store
// auto& store = getStore();

View File

@@ -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<dbal::CreateUserInput> 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<dbal::UpdateUserBatchItem> 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<std::string> 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<dbal::CreatePackageInput> 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<dbal::UpdatePackageBatchItem> 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<std::string> 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) {

View File

@@ -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"
}
}

View File

@@ -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<string, unknown>): Promise<unknown | null> {
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<string, unknown>)
}
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<unknown | null> {
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<string, unknown>)
}
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<string, unknown>,
updateData: Record<string, unknown>
): Promise<unknown> {
try {
const existing = await this.baseAdapter.findByField(entity, uniqueField, uniqueValue)
if (existing) {
this.checkPermission(entity, 'update')
this.checkRowLevelAccess(entity, 'update', existing as Record<string, unknown>)
} 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<string, unknown>): Promise<unknown> {
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<string, unknown>)
}
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<boolean> {
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<string, unknown>)
}
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<string, unknown>[]): Promise<number> {
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<string, unknown>, data: Record<string, unknown>): Promise<number> {
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<string, unknown>)
}
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<string, unknown>): Promise<number> {
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<string, unknown>)
}
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<AdapterCapabilities> {
return this.baseAdapter.getCapabilities()
}

View File

@@ -28,6 +28,7 @@ export interface DBALAdapter {
deleteByField(entity: string, field: string, value: unknown): Promise<boolean>
deleteMany(entity: string, filter?: Record<string, unknown>): Promise<number>
createMany(entity: string, data: Record<string, unknown>[]): Promise<number>
updateMany(entity: string, filter: Record<string, unknown>, data: Record<string, unknown>): Promise<number>
getCapabilities(): Promise<AdapterCapabilities>
close(): Promise<void>

View File

@@ -196,6 +196,19 @@ export class PrismaAdapter implements DBALAdapter {
}
}
async updateMany(entity: string, filter: Record<string, unknown>, data: Record<string, unknown>): Promise<number> {
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<string, unknown>[]): Promise<number> {
try {
const model = this.getModel(entity)

View File

@@ -129,6 +129,44 @@ export class WebSocketBridge implements DBALAdapter {
return this.call('list', entity, options) as Promise<ListResult<unknown>>
}
async findFirst(entity: string, filter?: Record<string, unknown>): Promise<unknown | null> {
return this.call('findFirst', entity, filter) as Promise<unknown | null>
}
async findByField(entity: string, field: string, value: unknown): Promise<unknown | null> {
return this.call('findByField', entity, field, value) as Promise<unknown | null>
}
async upsert(
entity: string,
uniqueField: string,
uniqueValue: unknown,
createData: Record<string, unknown>,
updateData: Record<string, unknown>
): Promise<unknown> {
return this.call('upsert', entity, uniqueField, uniqueValue, createData, updateData)
}
async updateByField(entity: string, field: string, value: unknown, data: Record<string, unknown>): Promise<unknown> {
return this.call('updateByField', entity, field, value, data)
}
async deleteByField(entity: string, field: string, value: unknown): Promise<boolean> {
return this.call('deleteByField', entity, field, value) as Promise<boolean>
}
async deleteMany(entity: string, filter?: Record<string, unknown>): Promise<number> {
return this.call('deleteMany', entity, filter) as Promise<number>
}
async createMany(entity: string, data: Record<string, unknown>[]): Promise<number> {
return this.call('createMany', entity, data) as Promise<number>
}
async updateMany(entity: string, filter: Record<string, unknown>, data: Record<string, unknown>): Promise<number> {
return this.call('updateMany', entity, filter, data) as Promise<number>
}
async getCapabilities(): Promise<AdapterCapabilities> {
return this.call('getCapabilities') as Promise<AdapterCapabilities>
}

View File

@@ -164,6 +164,66 @@ export class DBALClient {
list: async (options?: ListOptions): Promise<ListResult<User>> => {
return this.adapter.list('User', options) as Promise<ListResult<User>>
},
createMany: async (data: Array<Omit<User, 'id' | 'createdAt' | 'updatedAt'>>): Promise<number> => {
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<string, unknown>[])
} catch (error) {
if (error instanceof DBALError && error.code === 409) {
throw DBALError.conflict('Username or email already exists')
}
throw error
}
},
updateMany: async (filter: Record<string, unknown>, data: Partial<User>): Promise<number> => {
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<string, unknown>)
} catch (error) {
if (error instanceof DBALError && error.code === 409) {
throw DBALError.conflict('Username or email already exists')
}
throw error
}
},
deleteMany: async (filter: Record<string, unknown>): Promise<number> => {
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<ListResult<Package>> => {
return this.adapter.list('Package', options) as Promise<ListResult<Package>>
},
createMany: async (data: Array<Omit<Package, 'id' | 'createdAt' | 'updatedAt'>>): Promise<number> => {
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<string, unknown>[])
} catch (error) {
if (error instanceof DBALError && error.code === 409) {
throw DBALError.conflict('Package name+version already exists')
}
throw error
}
},
updateMany: async (filter: Record<string, unknown>, data: Partial<Package>): Promise<number> => {
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<string, unknown>)
} catch (error) {
if (error instanceof DBALError && error.code === 409) {
throw DBALError.conflict('Package name+version already exists')
}
throw error
}
},
deleteMany: async (filter: Record<string, unknown>): Promise<number> => {
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)
},
}
}

View File

@@ -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)
})
})

View File

@@ -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(),
}))

View File

@@ -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)

View File

@@ -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

View File

@@ -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.
---

File diff suppressed because it is too large Load Diff

View File

@@ -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",

View File

@@ -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('/')
}

View File

@@ -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<LoginPayload>(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
}

View File

@@ -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
}

View File

@@ -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<RegisterPayload>(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
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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')

File diff suppressed because it is too large Load Diff

View File

@@ -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

View File

@@ -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<HTMLDivElement, TabsProps>(
({ 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 (
<Box ref={ref} sx={{ display: 'flex', flexDirection: 'column', gap: 2 }} {...props}>
{/* Pass value down through children */}
{children}
</Box>
)
}
)
Tabs.displayName = 'Tabs'
interface TabsListProps {
children: ReactNode
className?: string
}
const TabsList = forwardRef<HTMLDivElement, TabsListProps>(
({ children, ...props }, ref) => {
return (
<Box
ref={ref}
sx={{
display: 'inline-flex',
height: 36,
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'action.hover',
borderRadius: 2,
p: 0.5,
}}
{...props}
>
{children}
</Box>
)
}
)
TabsList.displayName = 'TabsList'
interface TabsTriggerProps {
children: ReactNode
value: string
disabled?: boolean
className?: string
}
const TabsTrigger = forwardRef<HTMLButtonElement, TabsTriggerProps>(
({ children, value, disabled, ...props }, ref) => {
return (
<Box
ref={ref}
component="button"
role="tab"
data-value={value}
disabled={disabled}
sx={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
whiteSpace: 'nowrap',
px: 2,
py: 0.75,
fontSize: '0.875rem',
fontWeight: 500,
borderRadius: 1.5,
border: 0,
bgcolor: 'transparent',
color: 'text.secondary',
cursor: 'pointer',
transition: 'all 0.15s',
'&:hover': {
color: 'text.primary',
},
'&[aria-selected="true"]': {
bgcolor: 'background.paper',
color: 'text.primary',
boxShadow: 1,
},
'&:disabled': {
opacity: 0.5,
cursor: 'not-allowed',
},
}}
{...props}
>
{children}
</Box>
)
}
)
TabsTrigger.displayName = 'TabsTrigger'
interface TabsContentProps {
children: ReactNode
value: string
className?: string
}
const TabsContent = forwardRef<HTMLDivElement, TabsContentProps>(
({ children, value, ...props }, ref) => {
return (
<Box ref={ref} role="tabpanel" data-value={value} sx={{ flex: 1, outline: 'none' }} {...props}>
{children}
</Box>
)
}
)
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'

View File

@@ -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(
<Tabs defaultValue="alpha">
<TabsList>
<TabsTrigger value="alpha">Alpha</TabsTrigger>
<TabsTrigger value="beta">Beta</TabsTrigger>
</TabsList>
<TabsContent value="alpha">Alpha content</TabsContent>
<TabsContent value="beta">Beta content</TabsContent>
</Tabs>
)
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')
})
})

View File

@@ -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<HTMLDivElement, TabsProps>(
({ 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 (
<TabsContext.Provider value={{ value: currentValue, setValue: handleValueChange, idPrefix }}>
<Box
ref={ref}
sx={{ display: 'flex', flexDirection: 'column', gap: 2, ...sx }}
{...props}
>
{children}
</Box>
</TabsContext.Provider>
)
}
)
Tabs.displayName = 'Tabs'
export { Tabs }

View File

@@ -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<HTMLDivElement, TabsContentProps>(
({ 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 (
<Box
ref={ref}
role="tabpanel"
id={`${context.idPrefix}-panel-${value}`}
aria-labelledby={`${context.idPrefix}-tab-${value}`}
hidden={!isActive}
sx={{
flex: 1,
outline: 'none',
display: isActive ? 'block' : 'none',
...sx,
}}
{...props}
>
{children}
</Box>
)
}
)
TabsContent.displayName = 'TabsContent'
export { TabsContent }

View File

@@ -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<HTMLDivElement, TabsListProps>(
({ children, sx, ...props }, ref) => {
const context = useContext(TabsContext)
if (!context) {
throw new Error('TabsList must be used within Tabs')
}
return (
<Box
ref={ref}
role="tablist"
aria-orientation="horizontal"
sx={{
display: 'inline-flex',
height: 36,
alignItems: 'center',
justifyContent: 'center',
bgcolor: 'action.hover',
borderRadius: 2,
p: 0.5,
...sx,
}}
{...props}
>
{children}
</Box>
)
}
)
TabsList.displayName = 'TabsList'
export { TabsList }

View File

@@ -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<HTMLButtonElement, TabsTriggerProps>(
({ 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<HTMLButtonElement>) => {
if (disabled) return
onClick?.(event)
if (!event.defaultPrevented) {
context.setValue(value)
}
}
return (
<Box
ref={ref}
component="button"
type="button"
role="tab"
id={`${context.idPrefix}-tab-${value}`}
aria-controls={`${context.idPrefix}-panel-${value}`}
aria-selected={isSelected}
tabIndex={isSelected ? 0 : -1}
disabled={disabled}
data-state={isSelected ? 'active' : 'inactive'}
data-value={value}
onClick={handleClick}
sx={{
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
whiteSpace: 'nowrap',
px: 2,
py: 0.75,
fontSize: '0.875rem',
fontWeight: 500,
borderRadius: 1.5,
border: 0,
bgcolor: 'transparent',
color: 'text.secondary',
cursor: 'pointer',
transition: 'all 0.15s',
'&:hover': {
color: 'text.primary',
},
'&[aria-selected="true"]': {
bgcolor: 'background.paper',
color: 'text.primary',
boxShadow: 1,
},
'&:disabled': {
opacity: 0.5,
cursor: 'not-allowed',
},
...sx,
}}
{...props}
>
{children}
</Box>
)
}
)
TabsTrigger.displayName = 'TabsTrigger'
export { TabsTrigger }

View File

@@ -0,0 +1,9 @@
import { createContext } from 'react'
export interface TabsContextValue {
value: string
setValue: (value: string) => void
idPrefix: string
}
export const TabsContext = createContext<TabsContextValue | null>(null)

View File

@@ -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<string, number> = {
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<void> | null = null
getState(): AuthState {
return this.state
}
subscribe(listener: () => void): () => void {
this.listeners.add(listener)
return () => {
this.listeners.delete(listener)
}
}
async ensureSessionChecked(): Promise<void> {
if (!this.sessionCheckPromise) {
this.sessionCheckPromise = this.refresh().finally(() => {
this.sessionCheckPromise = null
})
}
return this.sessionCheckPromise
}
async login(identifier: string, password: string): Promise<void> {
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<void> {
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<void> {
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()

View File

@@ -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<void>
logout: () => Promise<void>
refresh: () => Promise<void>
}

View File

@@ -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'

View File

@@ -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>): 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<void>) => {
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()

View File

@@ -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<void>
logout: () => Promise<void>
refresh: () => Promise<void>
}
// 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>(authState)
const [state, setState] = useState<AuthState>(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 {

View File

@@ -0,0 +1,7 @@
export async function readJson<T>(request: Request): Promise<T | null> {
try {
return (await request.json()) as T
} catch {
return null
}
}

View File

@@ -0,0 +1,13 @@
import type { User } from '@/lib/level-types'
export async function fetchSession(): Promise<User | null> {
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
}
}

View File

@@ -0,0 +1,4 @@
export { fetchSession } from './fetch-session'
export { login } from './login'
export { logout } from './logout'
export { register } from './register'

View File

@@ -0,0 +1,22 @@
import type { User } from '@/lib/level-types'
export async function login(identifier: string, password: string, options?: { tenantId?: string }): Promise<User> {
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
}

View File

@@ -0,0 +1,8 @@
export async function logout(): Promise<void> {
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)
}
}

View File

@@ -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<User> {
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
}

View File

@@ -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,
})
}

View File

@@ -0,0 +1,2 @@
export const AUTH_COOKIE_NAME = 'mb_session'
export const DEFAULT_SESSION_TTL_MS = 1000 * 60 * 60 * 24 * 7

View File

@@ -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),
})
}

View File

@@ -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<AuthenticateResult> => {
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<string, unknown>
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 }
}

View File

@@ -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<User | null> => {
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<string, unknown>
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),
}
}

View File

@@ -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<User | null> => {
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<string, unknown>
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),
}
}

View File

@@ -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'

View File

@@ -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<boolean> {
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

View File

@@ -0,0 +1,11 @@
const PRIMARY_KEY_FIELDS: Record<string, string> = {
Credential: 'username',
InstalledPackage: 'packageId',
PackageData: 'packageId',
PasswordResetToken: 'username',
SystemConfig: 'key',
}
export function getPrimaryKeyField(entity: string): string {
return PRIMARY_KEY_FIELDS[entity] ?? 'id'
}

View File

@@ -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' },
})
})
})

View File

@@ -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<unknown | null> {
const model = getModel(entity)
return model.findUnique({ where: { id } })
const primaryKeyField = getPrimaryKeyField(entity)
return model.findUnique({ where: { [primaryKeyField]: id } })
}

View File

@@ -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<string, unknown>
): Promise<unknown> {
const model = getModel(entity)
const primaryKeyField = getPrimaryKeyField(entity)
// Filter out undefined values
const cleanData: Record<string, unknown> = {}
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 })
}

View File

@@ -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

View File

@@ -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,
})
})
})

View File

@@ -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<Session> {
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,
}
}

View File

@@ -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)
})
})

View File

@@ -0,0 +1,10 @@
import { getAdapter } from '../dbal-client'
export async function deleteSessionByToken(token: string): Promise<boolean> {
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
}

View File

@@ -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)
})
})

View File

@@ -0,0 +1,6 @@
import { getAdapter } from '../dbal-client'
export async function deleteSession(sessionId: string): Promise<boolean> {
const adapter = getAdapter()
return await adapter.delete('Session', sessionId)
}

View File

@@ -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<Session | null> {
const adapter = getAdapter()
const record = await adapter.read('Session', sessionId)
if (!record) return null
return mapSessionRecord(record)
}

View File

@@ -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')
})
})

View File

@@ -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<Session | null> {
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
}

View File

@@ -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'

View File

@@ -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,
},
])
})
})

View File

@@ -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<Session[]> {
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)
}

View File

@@ -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),
}
}

View File

@@ -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
}

View File

@@ -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,
})
})
})

View File

@@ -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<Session> {
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)
}

View File

@@ -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' },
})
})
})

View File

@@ -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<User | null> {
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,
}
}

View File

@@ -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' },
})
})
})

View File

@@ -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<User | null> {
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,
}
}

View File

@@ -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'

View File

@@ -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,
})
})
})

View File

@@ -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,
}
}

View File

@@ -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"
}
}

View File

@@ -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