@@ -190,148 +226,120 @@
{/if}
- {#each sections as section}
-
+ {#each categories as category (category.name)}
+
{#if !collapsed}
-
{section.title}
+
+ {category.name}
+
{/if}
- {#each section.items as item}
- {#if item.href === '/pulumi-state' && !collapsed}
+ {#each category.modules as navModule (navModule.name)}
+ {#if navModule.items.length === 1}
+ {@const item = navModule.items[0]}
+
+
+
+
+
+ {#if !collapsed}
+ {navModule.name}
+ {/if}
+
+ {:else if collapsed}
+
+
+
+
+
+ {:else}
{/each}
-
-
- {#if !collapsed}
-
- {/if}
-
- {#if collapsed}
-
-
-
-
-
- {:else}
-
-
-
- {#if settingsMenuOpen}
-
- {/if}
-
- {/if}
-
-
\ No newline at end of file
+
diff --git a/src/lib/database/schemas.ts b/src/lib/database/schemas.ts
index 697122b..dd5c620 100644
--- a/src/lib/database/schemas.ts
+++ b/src/lib/database/schemas.ts
@@ -7,9 +7,15 @@ export const UserEntity = entity('users', {
passwordHash: text().notNull(),
roleId: uuid().notNull(),
status: text().notNull().default('active'),
- authProviders: json().notNull().$defaultFn(() => []),
- createdAt: timestamp().notNull().$defaultFn(() => new Date().toISOString()),
- updatedAt: timestamp().notNull().$defaultFn(() => new Date().toISOString()),
+ authProviders: json()
+ .notNull()
+ .$defaultFn(() => []),
+ createdAt: timestamp()
+ .notNull()
+ .$defaultFn(() => new Date().toISOString()),
+ updatedAt: timestamp()
+ .notNull()
+ .$defaultFn(() => new Date().toISOString()),
lastLoginAt: timestamp(),
disabled: bool().notNull().default(false),
});
@@ -18,9 +24,15 @@ export const RoleEntity = entity('roles', {
id: uuid().primaryKey(),
slug: text().notNull(),
name: text().notNull(),
- permissions: json().notNull().$defaultFn(() => []),
- createdAt: timestamp().notNull().$defaultFn(() => new Date().toISOString()),
- updatedAt: timestamp().notNull().$defaultFn(() => new Date().toISOString()),
+ permissions: json()
+ .notNull()
+ .$defaultFn(() => []),
+ createdAt: timestamp()
+ .notNull()
+ .$defaultFn(() => new Date().toISOString()),
+ updatedAt: timestamp()
+ .notNull()
+ .$defaultFn(() => new Date().toISOString()),
});
export const ApiKeyEntity = entity('api_keys', {
@@ -32,7 +44,9 @@ export const ApiKeyEntity = entity('api_keys', {
expiresAt: timestamp(),
lastUsedAt: timestamp(),
revokedAt: timestamp(),
- createdAt: timestamp().notNull().$defaultFn(() => new Date().toISOString()),
+ createdAt: timestamp()
+ .notNull()
+ .$defaultFn(() => new Date().toISOString()),
});
export const relations = defineRelations();
@@ -44,4 +58,45 @@ relations.for(UserEntity, ({ one, many }) => ({
relations.for(ApiKeyEntity, ({ one }) => ({
user: one(UserEntity, { fields: ['userId'], references: ['id'] }),
-}));
\ No newline at end of file
+}));
+
+export const ProjectEntity = entity('projects', {
+ id: uuid().primaryKey(),
+ slug: text().notNull(),
+ name: text().notNull(),
+ description: text(),
+ status: text().notNull().default('active'),
+ modules: json()
+ .notNull()
+ .$defaultFn(() => ({ vault: true, openreport: true, stateiac: true })),
+ createdAt: timestamp()
+ .notNull()
+ .$defaultFn(() => new Date().toISOString()),
+ updatedAt: timestamp()
+ .notNull()
+ .$defaultFn(() => new Date().toISOString()),
+});
+
+export const ProjectRoleEntity = entity('project_roles', {
+ id: uuid().primaryKey(),
+ projectId: uuid().notNull(),
+ slug: text().notNull(),
+ name: text().notNull(),
+ permissions: json()
+ .notNull()
+ .$defaultFn(() => []),
+ createdAt: timestamp()
+ .notNull()
+ .$defaultFn(() => new Date().toISOString()),
+ updatedAt: timestamp()
+ .notNull()
+ .$defaultFn(() => new Date().toISOString()),
+});
+
+relations.for(ProjectEntity, ({ many }) => ({
+ roles: many(ProjectRoleEntity, { fields: ['id'], references: ['projectId'] }),
+}));
+
+relations.for(ProjectRoleEntity, ({ one }) => ({
+ project: one(ProjectEntity, { fields: ['projectId'], references: ['id'] }),
+}));
diff --git a/src/lib/database/sqlite.client.ts b/src/lib/database/sqlite.client.ts
deleted file mode 100644
index 1b7f717..0000000
--- a/src/lib/database/sqlite.client.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import type Database from 'better-sqlite3';
-import type { DatabaseClient, DatabaseParams } from './types';
-
-export class SqliteDatabaseClient implements DatabaseClient {
- constructor(private readonly sqlite: Database.Database) {}
-
- exec(sql: string): void {
- this.sqlite.exec(sql);
- }
-
- get(sql: string, params?: DatabaseParams): T | undefined {
- if (params === undefined) {
- return this.sqlite.prepare(sql).get() as T | undefined;
- }
-
- return this.sqlite.prepare(sql).get(params as never) as T | undefined;
- }
-
- all(sql: string, params?: DatabaseParams): T[] {
- if (params === undefined) {
- return this.sqlite.prepare(sql).all() as T[];
- }
-
- return this.sqlite.prepare(sql).all(params as never) as T[];
- }
-
- run(sql: string, params?: DatabaseParams): void {
- if (params === undefined) {
- this.sqlite.prepare(sql).run();
- return;
- }
-
- this.sqlite.prepare(sql).run(params as never);
- }
-
- transaction(callback: () => T): T {
- const wrapped = this.sqlite.transaction(callback);
- return wrapped();
- }
-}
diff --git a/src/lib/db.ts b/src/lib/db.ts
deleted file mode 100644
index b37a6ae..0000000
--- a/src/lib/db.ts
+++ /dev/null
@@ -1,125 +0,0 @@
-import Database from 'better-sqlite3';
-import path from 'path';
-import fs from 'fs';
-import { SqliteDatabaseClient } from './database/sqlite.client';
-
-const DB_DIR = path.resolve(process.cwd(), 'data', 'db');
-const DB_PATH = path.join(DB_DIR, 'states.sqlite');
-
-if (!fs.existsSync(DB_DIR)) {
- fs.mkdirSync(DB_DIR, { recursive: true });
-}
-
-export const db = new Database(DB_PATH);
-export const databaseClient = new SqliteDatabaseClient(db);
-
-db.pragma('journal_mode = WAL');
-
-db.exec(`
- CREATE TABLE IF NOT EXISTS users (
- id TEXT PRIMARY KEY,
- username TEXT UNIQUE NOT NULL,
- email TEXT,
- password_hash TEXT NOT NULL,
- role TEXT NOT NULL DEFAULT 'developer',
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP
- );
-
- CREATE TABLE IF NOT EXISTS config (
- id INTEGER PRIMARY KEY CHECK (id = 1),
- auth_method TEXT NOT NULL DEFAULT 'none',
- encryption_key TEXT,
- updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
- );
-
- CREATE TABLE IF NOT EXISTS projects (
- id TEXT PRIMARY KEY,
- name TEXT NOT NULL,
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP
- );
-
- CREATE TABLE IF NOT EXISTS storage_backends (
- id TEXT PRIMARY KEY,
- name TEXT NOT NULL,
- provider TEXT NOT NULL,
- bucket TEXT NOT NULL,
- region TEXT,
- access_key_id TEXT,
- secret_access_key TEXT,
- endpoint TEXT,
- gcp_project_id TEXT,
- gcp_credentials TEXT,
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP
- );
-
- CREATE TABLE IF NOT EXISTS stacks (
- id TEXT PRIMARY KEY,
- project_id TEXT NOT NULL,
- name TEXT NOT NULL,
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- FOREIGN KEY (project_id) REFERENCES projects (id) ON DELETE CASCADE
- );
-
- CREATE TABLE IF NOT EXISTS states (
- id TEXT PRIMARY KEY,
- stack_id TEXT NOT NULL,
- version INTEGER NOT NULL,
- checkpoint JSON NOT NULL,
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- FOREIGN KEY (stack_id) REFERENCES stacks (id) ON DELETE CASCADE
- );
-
- CREATE TABLE IF NOT EXISTS history (
- id TEXT PRIMARY KEY,
- stack_id TEXT NOT NULL,
- kind TEXT NOT NULL,
- start_time INTEGER NOT NULL,
- end_time INTEGER,
- message TEXT,
- environment JSON,
- config JSON,
- result TEXT,
- resource_changes JSON,
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- FOREIGN KEY (stack_id) REFERENCES stacks (id) ON DELETE CASCADE
- );
-
- CREATE TABLE IF NOT EXISTS api_keys (
- id TEXT PRIMARY KEY,
- user_id TEXT NOT NULL,
- name TEXT NOT NULL,
- key_prefix TEXT NOT NULL,
- key_hash TEXT NOT NULL,
- last_used_at DATETIME,
- revoked_at DATETIME,
- created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
- FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
- );
-`);
-
-try {
- db.exec('ALTER TABLE config ADD COLUMN encryption_key TEXT');
-} catch {
- // Ignore if column already exists
-}
-
-const migrations = [
- 'ALTER TABLE users ADD COLUMN email TEXT',
- 'ALTER TABLE config ADD COLUMN public_access INTEGER DEFAULT 1',
- 'ALTER TABLE config ADD COLUMN google_sso_enabled INTEGER DEFAULT 0',
- 'ALTER TABLE config ADD COLUMN google_client_id TEXT',
- 'ALTER TABLE config ADD COLUMN google_client_secret TEXT',
- 'ALTER TABLE config ADD COLUMN saml_enabled INTEGER DEFAULT 0',
- 'ALTER TABLE config ADD COLUMN saml_entry_point TEXT',
- 'ALTER TABLE config ADD COLUMN saml_issuer TEXT',
- 'ALTER TABLE config ADD COLUMN saml_cert TEXT',
- "ALTER TABLE users ADD COLUMN role TEXT DEFAULT 'developer'",
-];
-
-for (const query of migrations) {
- try {
- db.exec(query);
- } catch {
- // Ignore if already applied
- }
-}
diff --git a/src/lib/server/domain/domain.ts b/src/lib/server/domain/domain.ts
new file mode 100644
index 0000000..f4c2fc6
--- /dev/null
+++ b/src/lib/server/domain/domain.ts
@@ -0,0 +1,15 @@
+
+export class Domain {
+ public id: string = ''
+ public createdAt: Date = new Date()
+ public updatedAt: Date = new Date()
+
+ constructor(data: any) {
+ this.id = data.id
+ this.createdAt = new Date(data.createdAt)
+ this.updatedAt = new Date(data.updatedAt)
+ }
+ toJson() {
+ return JSON.parse(JSON.stringify(this));
+ }
+}
\ No newline at end of file
diff --git a/src/lib/server/infra/repository.ts b/src/lib/server/infra/repository.ts
new file mode 100644
index 0000000..6105e33
--- /dev/null
+++ b/src/lib/server/infra/repository.ts
@@ -0,0 +1,12 @@
+import { getGitDb } from '$lib/server/gitdb';
+export class Repository {
+ protected readonly db = getGitDb();
+
+ protected toDomain(row: any): any {
+ return row;
+ }
+
+ protected toJSON(row: any): any {
+ return row;
+ }
+}
\ No newline at end of file
diff --git a/src/modules/config/application/config.service.ts b/src/modules/config/application/config.service.ts
deleted file mode 100644
index 39fdac7..0000000
--- a/src/modules/config/application/config.service.ts
+++ /dev/null
@@ -1,22 +0,0 @@
-import type { InstanceConfig } from '../domain/entities';
-import type { ConfigRepository } from '../domain/repositories';
-
-const DEFAULT_CONFIG: InstanceConfig = {
- publicAccess: false,
- googleSsoEnabled: false,
- samlEnabled: false,
-};
-
-export class ConfigService {
- constructor(private readonly configRepository: ConfigRepository) {}
-
- getConfig(): InstanceConfig | null {
- return this.configRepository.getConfig();
- }
-
- saveConfig(partial: Partial): void {
- const existing = this.getConfig() ?? DEFAULT_CONFIG;
- const merged: InstanceConfig = { ...existing, ...partial };
- this.configRepository.saveConfig(merged);
- }
-}
diff --git a/src/modules/config/application/storage-backend.service.ts b/src/modules/config/application/storage-backend.service.ts
deleted file mode 100644
index 70976f6..0000000
--- a/src/modules/config/application/storage-backend.service.ts
+++ /dev/null
@@ -1,50 +0,0 @@
-import crypto from 'crypto';
-import type {
- StorageBackend,
- StorageBackendPublic,
- UpsertStorageBackendInput,
-} from '../domain/entities';
-import type { StorageBackendRepository } from '../domain/repositories';
-
-export class StorageBackendService {
- constructor(private readonly repository: StorageBackendRepository) {}
-
- list(): StorageBackendPublic[] {
- return this.repository.list().map((backend) => ({
- ...backend,
- secretAccessKey: backend.secretAccessKey ? '***' : null,
- gcpCredentials: backend.gcpCredentials ? '***' : null,
- }));
- }
-
- getById(id: string): StorageBackend | null {
- return this.repository.findById(id);
- }
-
- upsert(input: UpsertStorageBackendInput): string {
- const id = input.id || crypto.randomUUID();
- const existing = input.id ? this.repository.findById(input.id) : null;
-
- const secretAccessKey =
- input.secretAccessKey === '***' ? (existing?.secretAccessKey ?? null) : (input.secretAccessKey ?? null);
- const gcpCredentials =
- input.gcpCredentials === '***' ? (existing?.gcpCredentials ?? null) : (input.gcpCredentials ?? null);
-
- return this.repository.upsert({
- id,
- name: input.name || 'Unnamed Backend',
- provider: input.provider,
- bucket: input.bucket,
- region: input.region ?? null,
- accessKeyId: input.accessKeyId ?? null,
- secretAccessKey,
- endpoint: input.endpoint ?? null,
- gcpProjectId: input.gcpProjectId ?? null,
- gcpCredentials,
- });
- }
-
- deleteById(id: string): void {
- this.repository.deleteById(id);
- }
-}
diff --git a/src/modules/config/domain/entities.ts b/src/modules/config/domain/entities.ts
deleted file mode 100644
index 142783b..0000000
--- a/src/modules/config/domain/entities.ts
+++ /dev/null
@@ -1,51 +0,0 @@
-export interface InstanceConfig {
- publicAccess: boolean;
- googleSsoEnabled: boolean;
- googleClientId?: string | null;
- googleClientSecret?: string | null;
- samlEnabled: boolean;
- samlEntryPoint?: string | null;
- samlIssuer?: string | null;
- samlCert?: string | null;
-}
-
-export type StorageProvider = 's3' | 'gcs';
-
-export interface StorageBackend {
- id: string;
- name: string;
- provider: StorageProvider;
- bucket: string;
- region?: string | null;
- accessKeyId?: string | null;
- secretAccessKey?: string | null;
- endpoint?: string | null;
- gcpProjectId?: string | null;
- gcpCredentials?: string | null;
-}
-
-export interface StorageBackendPublic {
- id: string;
- name: string;
- provider: StorageProvider;
- bucket: string;
- region?: string | null;
- accessKeyId?: string | null;
- secretAccessKey?: string | null;
- endpoint?: string | null;
- gcpProjectId?: string | null;
- gcpCredentials?: string | null;
-}
-
-export interface UpsertStorageBackendInput {
- id?: string;
- name?: string;
- provider: StorageProvider;
- bucket: string;
- region?: string | null;
- accessKeyId?: string | null;
- secretAccessKey?: string | null;
- endpoint?: string | null;
- gcpProjectId?: string | null;
- gcpCredentials?: string | null;
-}
diff --git a/src/modules/config/domain/repositories.ts b/src/modules/config/domain/repositories.ts
deleted file mode 100644
index 3a849fc..0000000
--- a/src/modules/config/domain/repositories.ts
+++ /dev/null
@@ -1,13 +0,0 @@
-import type { InstanceConfig, StorageBackend } from './entities';
-
-export interface ConfigRepository {
- getConfig(): InstanceConfig | null;
- saveConfig(config: InstanceConfig): void;
-}
-
-export interface StorageBackendRepository {
- list(): StorageBackend[];
- findById(id: string): StorageBackend | null;
- upsert(backend: StorageBackend): string;
- deleteById(id: string): void;
-}
diff --git a/src/modules/config/index.ts b/src/modules/config/index.ts
deleted file mode 100644
index ff4f7fa..0000000
--- a/src/modules/config/index.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import { databaseClient } from '$lib/db';
-import { ConfigService } from './application/config.service';
-import { StorageBackendService } from './application/storage-backend.service';
-import { SqliteConfigRepository } from './infrastructure/repositories/sqlite-config.repository';
-import { SqliteStorageBackendRepository } from './infrastructure/repositories/sqlite-storage-backend.repository';
-
-const configRepository = new SqliteConfigRepository(databaseClient);
-const storageBackendRepository = new SqliteStorageBackendRepository(databaseClient);
-
-export const configService = new ConfigService(configRepository);
-export const storageBackendService = new StorageBackendService(storageBackendRepository);
diff --git a/src/modules/config/infrastructure/repositories/sqlite-config.repository.ts b/src/modules/config/infrastructure/repositories/sqlite-config.repository.ts
deleted file mode 100644
index dad8522..0000000
--- a/src/modules/config/infrastructure/repositories/sqlite-config.repository.ts
+++ /dev/null
@@ -1,75 +0,0 @@
-import type { DatabaseClient } from '$lib/database/types';
-import type { InstanceConfig } from '../../domain/entities';
-import type { ConfigRepository } from '../../domain/repositories';
-
-type ConfigRow = {
- public_access?: number | null;
- google_sso_enabled?: number | null;
- google_client_id?: string | null;
- google_client_secret?: string | null;
- saml_enabled?: number | null;
- saml_entry_point?: string | null;
- saml_issuer?: string | null;
- saml_cert?: string | null;
-};
-
-export class SqliteConfigRepository implements ConfigRepository {
- constructor(private readonly db: DatabaseClient) {}
-
- getConfig(): InstanceConfig | null {
- const row = this.db.get('SELECT * FROM config WHERE id = 1');
- if (!row) {
- return null;
- }
-
- const publicAccess = row.public_access !== undefined ? row.public_access === 1 : true;
-
- return {
- publicAccess,
- googleSsoEnabled: row.google_sso_enabled === 1,
- googleClientId: row.google_client_id,
- googleClientSecret: row.google_client_secret,
- samlEnabled: row.saml_enabled === 1,
- samlEntryPoint: row.saml_entry_point,
- samlIssuer: row.saml_issuer,
- samlCert: row.saml_cert,
- };
- }
-
- saveConfig(config: InstanceConfig): void {
- this.db.run(
- `
- INSERT INTO config (
- id, updated_at, public_access, google_sso_enabled,
- google_client_id, google_client_secret,
- saml_enabled, saml_entry_point, saml_issuer, saml_cert
- )
- VALUES (
- 1, CURRENT_TIMESTAMP, @publicAccess, @googleSsoEnabled,
- @googleClientId, @googleClientSecret,
- @samlEnabled, @samlEntryPoint, @samlIssuer, @samlCert
- )
- ON CONFLICT(id) DO UPDATE SET
- public_access = excluded.public_access,
- google_sso_enabled = excluded.google_sso_enabled,
- google_client_id = excluded.google_client_id,
- google_client_secret = excluded.google_client_secret,
- saml_enabled = excluded.saml_enabled,
- saml_entry_point = excluded.saml_entry_point,
- saml_issuer = excluded.saml_issuer,
- saml_cert = excluded.saml_cert,
- updated_at = CURRENT_TIMESTAMP
- `,
- {
- publicAccess: config.publicAccess ? 1 : 0,
- googleSsoEnabled: config.googleSsoEnabled ? 1 : 0,
- googleClientId: config.googleClientId || null,
- googleClientSecret: config.googleClientSecret || null,
- samlEnabled: config.samlEnabled ? 1 : 0,
- samlEntryPoint: config.samlEntryPoint || null,
- samlIssuer: config.samlIssuer || null,
- samlCert: config.samlCert || null,
- },
- );
- }
-}
diff --git a/src/modules/config/infrastructure/repositories/sqlite-storage-backend.repository.ts b/src/modules/config/infrastructure/repositories/sqlite-storage-backend.repository.ts
deleted file mode 100644
index 6a73ae0..0000000
--- a/src/modules/config/infrastructure/repositories/sqlite-storage-backend.repository.ts
+++ /dev/null
@@ -1,89 +0,0 @@
-import type { DatabaseClient } from '$lib/database/types';
-import type { StorageBackend, StorageProvider } from '../../domain/entities';
-import type { StorageBackendRepository } from '../../domain/repositories';
-
-type StorageBackendRow = {
- id: string;
- name: string;
- provider: string;
- bucket: string;
- region?: string | null;
- access_key_id?: string | null;
- secret_access_key?: string | null;
- endpoint?: string | null;
- gcp_project_id?: string | null;
- gcp_credentials?: string | null;
-};
-
-export class SqliteStorageBackendRepository implements StorageBackendRepository {
- constructor(private readonly db: DatabaseClient) {}
-
- list(): StorageBackend[] {
- const rows = this.db.all('SELECT * FROM storage_backends ORDER BY created_at DESC');
- return rows.map((row) => this.toEntity(row));
- }
-
- findById(id: string): StorageBackend | null {
- const row = this.db.get('SELECT * FROM storage_backends WHERE id = ?', [id]);
- return row ? this.toEntity(row) : null;
- }
-
- upsert(backend: StorageBackend): string {
- this.db.run(
- `
- INSERT INTO storage_backends (
- id, name, provider, bucket, region, access_key_id, secret_access_key, endpoint, gcp_project_id, gcp_credentials
- ) VALUES (
- @id, @name, @provider, @bucket, @region, @accessKeyId, @secretAccessKey, @endpoint, @gcpProjectId, @gcpCredentials
- )
- ON CONFLICT(id) DO UPDATE SET
- name = excluded.name,
- provider = excluded.provider,
- bucket = excluded.bucket,
- region = excluded.region,
- access_key_id = excluded.access_key_id,
- secret_access_key = excluded.secret_access_key,
- endpoint = excluded.endpoint,
- gcp_project_id = excluded.gcp_project_id,
- gcp_credentials = excluded.gcp_credentials
- `,
- {
- id: backend.id,
- name: backend.name,
- provider: backend.provider,
- bucket: backend.bucket,
- region: backend.region || null,
- accessKeyId: backend.accessKeyId || null,
- secretAccessKey: backend.secretAccessKey || null,
- endpoint: backend.endpoint || null,
- gcpProjectId: backend.gcpProjectId || null,
- gcpCredentials: backend.gcpCredentials || null,
- },
- );
-
- return backend.id;
- }
-
- deleteById(id: string): void {
- this.db.run('DELETE FROM storage_backends WHERE id = ?', [id]);
- }
-
- private toEntity(row: StorageBackendRow): StorageBackend {
- return {
- id: row.id,
- name: row.name,
- provider: this.normalizeProvider(row.provider),
- bucket: row.bucket,
- region: row.region,
- accessKeyId: row.access_key_id,
- secretAccessKey: row.secret_access_key,
- endpoint: row.endpoint,
- gcpProjectId: row.gcp_project_id,
- gcpCredentials: row.gcp_credentials,
- };
- }
-
- private normalizeProvider(provider: string): StorageProvider {
- return provider === 'gcs' ? 'gcs' : 's3';
- }
-}
diff --git a/src/modules/organization/application/organization.service.ts b/src/modules/organization/application/organization.service.ts
new file mode 100644
index 0000000..71d885b
--- /dev/null
+++ b/src/modules/organization/application/organization.service.ts
@@ -0,0 +1,8 @@
+export type Organization = { id: string; name: string; slug: string };
+
+export class OrganizationService {
+ // stub: no persistence yet, always resolves to the single default organization
+ async findBySlug(_slug: string): Promise {
+ return { id: 'gitops', name: 'GitOps', slug: 'gitops' };
+ }
+}
diff --git a/src/modules/organization/index.ts b/src/modules/organization/index.ts
new file mode 100644
index 0000000..635db93
--- /dev/null
+++ b/src/modules/organization/index.ts
@@ -0,0 +1,4 @@
+import { OrganizationService } from './application/organization.service';
+
+export const organizationService = new OrganizationService();
+export type { Organization } from './application/organization.service';
diff --git a/src/modules/projects/application/project.service.test.ts b/src/modules/projects/application/project.service.test.ts
new file mode 100644
index 0000000..6616a25
--- /dev/null
+++ b/src/modules/projects/application/project.service.test.ts
@@ -0,0 +1,225 @@
+import { describe, expect, it, beforeEach } from 'vitest';
+import { ProjectService } from './project.service';
+import { ProjectDomain } from '../domain/project.domain';
+
+class FakeProjectRepository {
+ rows: ProjectDomain[] = [];
+
+ async findAll() {
+ return [...this.rows];
+ }
+
+ async findById(id: string) {
+ return this.rows.find((p) => p.id === id) ?? null;
+ }
+
+ async findBySlug(slug: string) {
+ return this.rows.find((p) => p.slug === slug) ?? null;
+ }
+
+ async create(input: {
+ id: string;
+ slug: string;
+ name: string;
+ description?: string;
+ status: string;
+ modules: { vault: boolean; openreport: boolean; stateiac: boolean };
+ }) {
+ this.rows.push(
+ new ProjectDomain({
+ id: input.id,
+ slug: input.slug,
+ name: input.name,
+ description: input.description,
+ status: input.status,
+ modules: input.modules,
+ createdAt: '2024-01-01T00:00:00.000Z',
+ updatedAt: '2024-01-01T00:00:00.000Z',
+ }),
+ );
+ }
+
+ async update(
+ id: string,
+ changes: {
+ name?: string;
+ slug?: string;
+ description?: string;
+ status?: string;
+ modules?: { vault: boolean; openreport: boolean; stateiac: boolean };
+ },
+ ) {
+ const project = this.rows.find((p) => p.id === id);
+ if (!project) return;
+ if (changes.name !== undefined) project.name = changes.name;
+ if (changes.slug !== undefined) project.slug = changes.slug;
+ if (changes.description !== undefined) project.description = changes.description;
+ if (changes.status !== undefined) project.status = changes.status as 'active' | 'inactive';
+ if (changes.modules !== undefined) project.modules = changes.modules;
+ }
+
+ async deleteById(id: string) {
+ this.rows = this.rows.filter((p) => p.id !== id);
+ }
+}
+
+describe('ProjectService', () => {
+ let repository: FakeProjectRepository;
+ let service: ProjectService;
+
+ beforeEach(() => {
+ repository = new FakeProjectRepository();
+ service = new ProjectService(repository as any);
+ });
+
+ describe('listProjects', () => {
+ it('returns an empty list when there are no projects', async () => {
+ expect(await service.listProjects()).toEqual([]);
+ });
+
+ it('lists created projects', async () => {
+ await service.createProject({ name: 'Platform Core' });
+ const projects = await service.listProjects();
+ expect(projects).toHaveLength(1);
+ expect(projects[0].slug).toBe('platform-core');
+ });
+ });
+
+ describe('getProject / getProjectBySlug', () => {
+ it('throws when the project does not exist', async () => {
+ await expect(service.getProject('missing-id')).rejects.toThrow(/not found/);
+ await expect(service.getProjectBySlug('missing-slug')).rejects.toThrow(/not found/);
+ });
+
+ it('returns the project by id and by slug', async () => {
+ const created = await service.createProject({ name: 'Kettu Studio' });
+
+ const byId = await service.getProject(created.id);
+ expect(byId.slug).toBe('kettu-studio');
+
+ const bySlug = await service.getProjectBySlug('kettu-studio');
+ expect(bySlug.id).toBe(created.id);
+ });
+ });
+
+ describe('createProject', () => {
+ it('requires a non-empty name', async () => {
+ await expect(service.createProject({ name: ' ' })).rejects.toThrow(/name is required/);
+ });
+
+ it('auto-generates a normalized slug from the name when none is provided', async () => {
+ const project = await service.createProject({ name: 'Kettu Studio!!' });
+ expect(project.slug).toBe('kettu-studio');
+ });
+
+ it('uses the provided slug, normalized', async () => {
+ const project = await service.createProject({ name: 'Kettu Studio', slug: 'Custom Slug' });
+ expect(project.slug).toBe('custom-slug');
+ });
+
+ it('rejects creating a project with a duplicate slug', async () => {
+ await service.createProject({ name: 'Kettu Studio' });
+ await expect(service.createProject({ name: 'Kettu Studio' })).rejects.toThrow(
+ /already exists/,
+ );
+ });
+
+ it('defaults status to active for missing or invalid values', async () => {
+ const project = await service.createProject({ name: 'Project A' });
+ expect(project.status).toBe('active');
+ });
+
+ it('accepts an explicit inactive status', async () => {
+ const project = await service.createProject({ name: 'Project B', status: 'inactive' });
+ expect(project.status).toBe('inactive');
+ });
+
+ it('defaults all modules to enabled when none are provided', async () => {
+ const project = await service.createProject({ name: 'Project C' });
+ expect(project.modules).toEqual({ vault: true, openreport: true, stateiac: true });
+ });
+
+ it('accepts a partial modules override, defaulting the rest to enabled', async () => {
+ const project = await service.createProject({
+ name: 'Project D',
+ modules: { vault: false },
+ });
+ expect(project.modules).toEqual({ vault: false, openreport: true, stateiac: true });
+ });
+ });
+
+ describe('updateProject', () => {
+ it('throws when the project does not exist', async () => {
+ await expect(service.updateProject('missing-id', { name: 'X' })).rejects.toThrow(/not found/);
+ });
+
+ it('updates only the fields provided', async () => {
+ const created = await service.createProject({ name: 'Original Name' });
+
+ const updated = await service.updateProject(created.id, { description: 'New description' });
+ expect(updated.name).toBe('Original Name');
+ expect(updated.description).toBe('New description');
+ });
+
+ it('rejects clearing the name', async () => {
+ const created = await service.createProject({ name: 'Original Name' });
+ await expect(service.updateProject(created.id, { name: ' ' })).rejects.toThrow(
+ /name is required/,
+ );
+ });
+
+ it('normalizes the slug when updating it', async () => {
+ const created = await service.createProject({ name: 'Original Name' });
+ const updated = await service.updateProject(created.id, { slug: 'New Slug!!' });
+ expect(updated.slug).toBe('new-slug');
+ });
+
+ it('rejects updating to a slug already used by another project', async () => {
+ await service.createProject({ name: 'Project A', slug: 'taken' });
+ const created = await service.createProject({ name: 'Project B' });
+
+ await expect(service.updateProject(created.id, { slug: 'taken' })).rejects.toThrow(
+ /already exists/,
+ );
+ });
+
+ it('allows keeping the same slug on the same project', async () => {
+ const created = await service.createProject({ name: 'Project A', slug: 'same-slug' });
+ const updated = await service.updateProject(created.id, { slug: 'same-slug' });
+ expect(updated.slug).toBe('same-slug');
+ });
+
+ it('merges partial module updates with the existing modules', async () => {
+ const created = await service.createProject({ name: 'Project A' });
+
+ const updated = await service.updateProject(created.id, { modules: { vault: false } });
+ expect(updated.modules).toEqual({ vault: false, openreport: true, stateiac: true });
+
+ const updatedAgain = await service.updateProject(created.id, {
+ modules: { openreport: false },
+ });
+ expect(updatedAgain.modules).toEqual({ vault: false, openreport: false, stateiac: true });
+ });
+
+ it('updates the status', async () => {
+ const created = await service.createProject({ name: 'Project A' });
+ const updated = await service.updateProject(created.id, { status: 'inactive' });
+ expect(updated.status).toBe('inactive');
+ });
+ });
+
+ describe('deleteProject', () => {
+ it('throws when the project does not exist', async () => {
+ await expect(service.deleteProject('missing-id')).rejects.toThrow(/not found/);
+ });
+
+ it('deletes an existing project', async () => {
+ const created = await service.createProject({ name: 'Project A' });
+
+ await service.deleteProject(created.id);
+
+ const projects = await service.listProjects();
+ expect(projects.find((p) => p.id === created.id)).toBeUndefined();
+ });
+ });
+});
diff --git a/src/modules/projects/application/project.service.ts b/src/modules/projects/application/project.service.ts
index c07c61e..89a92d7 100644
--- a/src/modules/projects/application/project.service.ts
+++ b/src/modules/projects/application/project.service.ts
@@ -1,48 +1,164 @@
-import type { Project } from '../domain/entities';
-import type { ProjectRepository } from '../domain/repositories';
+import crypto from 'crypto';
+import { ProjectRepository } from '../infrastructure/repositories/project.repostitory';
+import { DEFAULT_PROJECT_MODULES, type ProjectModules } from '../domain/project.domain';
+
+export type ProjectStatusValue = 'active' | 'inactive';
export class ProjectService {
constructor(private readonly repository: ProjectRepository) {}
- listProjects(): Project[] {
- return this.repository.list();
+ async listProjects() {
+ const projects = await this.repository.findAll();
+ return projects.map((project) => project.toJson());
}
- createProject(name: string): Project {
- const normalizedName = name.trim();
- if (!normalizedName) {
+ async getProject(id: string) {
+ const project = await this.repository.findById(id);
+ if (!project) {
+ throw new Error('Project not found');
+ }
+ return project.toJson();
+ }
+
+ async getProjectBySlug(slug: string) {
+ const project = await this.repository.findBySlug(slug);
+ if (!project) {
+ throw new Error('Project not found');
+ }
+ return project.toJson();
+ }
+
+ async createProject(input: {
+ name: string;
+ slug?: string;
+ description?: string;
+ status?: string;
+ modules?: Partial;
+ }) {
+ const name = input.name.trim();
+ if (!name) {
throw new Error('Project name is required');
}
- const id = normalizedName.toLowerCase().replace(/[^a-z0-9-]/g, '-');
- this.repository.upsertIfMissing({ id, name: normalizedName });
+ const slug = this.normalizeSlug(input.slug || name);
+ if (!slug) {
+ throw new Error('Project slug is required');
+ }
+
+ const existing = await this.repository.findBySlug(slug);
+ if (existing) {
+ throw new Error('A project with this slug already exists');
+ }
+
+ await this.repository.create({
+ id: crypto.randomUUID(),
+ slug,
+ name,
+ description: input.description?.trim() || undefined,
+ status: this.sanitizeStatus(input.status),
+ modules: this.sanitizeModules(input.modules),
+ });
- return { id, name: normalizedName };
+ const created = await this.repository.findBySlug(slug);
+ if (!created) {
+ throw new Error('Failed to create project');
+ }
+
+ return created.toJson();
}
- syncFromPulumiStateKeys(keys: string[]): number {
- const projects = new Set();
+ async updateProject(
+ id: string,
+ changes: {
+ name?: string;
+ slug?: string;
+ description?: string;
+ status?: string;
+ modules?: Partial;
+ },
+ ) {
+ const project = await this.repository.findById(id);
+ if (!project) {
+ throw new Error('Project not found');
+ }
+
+ const patch: {
+ name?: string;
+ slug?: string;
+ description?: string;
+ status?: string;
+ modules?: ProjectModules;
+ } = {};
+
+ if (changes.name !== undefined) {
+ const name = changes.name.trim();
+ if (!name) {
+ throw new Error('Project name is required');
+ }
+ patch.name = name;
+ }
- for (const key of keys) {
- let id = key;
- if (id.startsWith('.pulumi/stacks/')) {
- id = id.replace('.pulumi/stacks/', '');
+ if (changes.slug !== undefined) {
+ const slug = this.normalizeSlug(changes.slug);
+ if (!slug) {
+ throw new Error('Project slug is required');
}
- if (id.endsWith('.json')) {
- id = id.slice(0, -5);
+
+ const existing = await this.repository.findBySlug(slug);
+ if (existing && existing.id !== id) {
+ throw new Error('A project with this slug already exists');
}
+ patch.slug = slug;
+ }
+
+ if (changes.description !== undefined) {
+ patch.description = changes.description.trim();
+ }
+
+ if (changes.status !== undefined) {
+ patch.status = this.sanitizeStatus(changes.status);
+ }
+
+ if (changes.modules !== undefined) {
+ patch.modules = this.sanitizeModules({ ...project.modules, ...changes.modules });
+ }
+
+ await this.repository.update(id, patch);
- const parts = id.split('/');
- projects.add(parts.length > 1 ? parts[0] : 'default');
+ const updated = await this.repository.findById(id);
+ if (!updated) {
+ throw new Error('Failed to update project');
}
- this.repository.upsertManyIfMissing(
- Array.from(projects).map((project) => ({
- id: project,
- name: project,
- })),
- );
+ return updated.toJson();
+ }
+
+ async deleteProject(id: string) {
+ const project = await this.repository.findById(id);
+ if (!project) {
+ throw new Error('Project not found');
+ }
+
+ await this.repository.deleteById(id);
+ }
+
+ private normalizeSlug(value: string): string {
+ return value
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '');
+ }
+
+ private sanitizeStatus(status?: string): ProjectStatusValue {
+ return status === 'inactive' ? 'inactive' : 'active';
+ }
- return projects.size;
+ private sanitizeModules(modules?: Partial): ProjectModules {
+ return {
+ vault: modules?.vault ?? DEFAULT_PROJECT_MODULES.vault,
+ openreport: modules?.openreport ?? DEFAULT_PROJECT_MODULES.openreport,
+ stateiac: modules?.stateiac ?? DEFAULT_PROJECT_MODULES.stateiac,
+ };
}
}
diff --git a/src/modules/projects/domain/entities.ts b/src/modules/projects/domain/entities.ts
deleted file mode 100644
index 6514b74..0000000
--- a/src/modules/projects/domain/entities.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-export interface Project {
- id: string;
- name: string;
- createdAt?: string;
-}
diff --git a/src/modules/projects/domain/project.domain.ts b/src/modules/projects/domain/project.domain.ts
new file mode 100644
index 0000000..f4d927d
--- /dev/null
+++ b/src/modules/projects/domain/project.domain.ts
@@ -0,0 +1,47 @@
+import { Domain } from '$lib/server/domain/domain';
+
+export interface ProjectStatus {
+ ACTIVE: 'active';
+ INACTIVE: 'inactive';
+}
+
+export interface ProjectModules {
+ vault: boolean;
+ openreport: boolean;
+ stateiac: boolean;
+}
+
+export const DEFAULT_PROJECT_MODULES: ProjectModules = {
+ vault: true,
+ openreport: true,
+ stateiac: true,
+};
+
+export class ProjectDomain extends Domain {
+ public name: string = '';
+ public slug: string | null = null;
+ public description?: string | null = null;
+ public status: ProjectStatus[keyof ProjectStatus] = 'active';
+ public modules: ProjectModules = { ...DEFAULT_PROJECT_MODULES };
+ constructor(data: any) {
+ super(data);
+ this.name = data.name;
+ this.slug = data.slug;
+ this.description = data.description;
+ this.status = data.status;
+ this.modules = { ...DEFAULT_PROJECT_MODULES, ...(data.modules ?? {}) };
+ }
+
+ toJson() {
+ return {
+ id: this.id,
+ name: this.name,
+ slug: this.slug,
+ description: this.description,
+ createdAt: this.createdAt,
+ status: this.status,
+ modules: this.modules,
+ updatedAt: this.updatedAt,
+ };
+ }
+}
diff --git a/src/modules/projects/domain/repositories.ts b/src/modules/projects/domain/repositories.ts
deleted file mode 100644
index faa1e4b..0000000
--- a/src/modules/projects/domain/repositories.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import type { Project } from './entities';
-
-export interface ProjectRepository {
- list(): Project[];
- upsertIfMissing(project: { id: string; name: string }): void;
- upsertManyIfMissing(projects: Array<{ id: string; name: string }>): void;
-}
diff --git a/src/modules/projects/index.ts b/src/modules/projects/index.ts
index caa06a1..aab224f 100644
--- a/src/modules/projects/index.ts
+++ b/src/modules/projects/index.ts
@@ -1,7 +1,6 @@
-import { databaseClient } from '$lib/db';
import { ProjectService } from './application/project.service';
-import { SqliteProjectRepository } from './infrastructure/repositories/sqlite-project.repository';
+import { ProjectRepository } from './infrastructure/repositories/project.repostitory';
-const projectRepository = new SqliteProjectRepository(databaseClient);
+const projectRepository = new ProjectRepository();
export const projectService = new ProjectService(projectRepository);
diff --git a/src/modules/projects/infrastructure/repositories/project.repostitory.ts b/src/modules/projects/infrastructure/repositories/project.repostitory.ts
new file mode 100644
index 0000000..c80539e
--- /dev/null
+++ b/src/modules/projects/infrastructure/repositories/project.repostitory.ts
@@ -0,0 +1,68 @@
+import { Repository } from '$lib/server/infra/repository';
+import { ProjectDomain, type ProjectModules } from '../../domain/project.domain';
+import { ProjectEntity } from '$lib/database/schemas';
+
+export class ProjectRepository extends Repository {
+ async findByName(name: string): Promise {
+ const role = await this.db.select().from(ProjectEntity).where({ name }).limit(1);
+ return role.rows[0] ? new ProjectDomain(role.rows[0]) : null;
+ }
+
+ async findAll(): Promise {
+ const result = await this.db.select().from(ProjectEntity).orderBy('createdAt', 'asc');
+ return result.rows.map((row: any) => new ProjectDomain(row));
+ }
+
+ async findById(id: string): Promise {
+ const result = await this.db.select().from(ProjectEntity).where({ id }).limit(1);
+ const row = result.rows[0];
+ return row ? new ProjectDomain(row) : null;
+ }
+
+ async findBySlug(slug: string): Promise {
+ const result = await this.db.select().from(ProjectEntity).where({ slug }).limit(1);
+ const row = result.rows[0];
+ return row ? new ProjectDomain(row) : null;
+ }
+
+ async create(input: {
+ id: string;
+ slug: string;
+ name: string;
+ description?: string;
+ status: string;
+ modules: ProjectModules;
+ }): Promise {
+ await this.db.insert(ProjectEntity).values({
+ id: input.id,
+ slug: input.slug,
+ name: input.name,
+ description: input.description,
+ status: input.status,
+ modules: input.modules,
+ createdAt: new Date().toISOString(),
+ updatedAt: new Date().toISOString(),
+ });
+ }
+
+ async update(
+ id: string,
+ changes: {
+ name?: string;
+ slug?: string;
+ description?: string;
+ status?: string;
+ modules?: ProjectModules;
+ },
+ ): Promise {
+ await this.db
+ .update(ProjectEntity)
+ .set({ ...changes, updatedAt: new Date().toISOString() })
+ .where({ id });
+ }
+
+ async deleteById(id: string): Promise {
+ // TODO: Aqui deberemos borrar roles, usuarios y demás entidades relacionadas con el proyecto antes de borrarlo
+ await this.db.delete(ProjectEntity).where({ id });
+ }
+}
diff --git a/src/modules/projects/infrastructure/repositories/sqlite-project.repository.ts b/src/modules/projects/infrastructure/repositories/sqlite-project.repository.ts
deleted file mode 100644
index 6a6ff83..0000000
--- a/src/modules/projects/infrastructure/repositories/sqlite-project.repository.ts
+++ /dev/null
@@ -1,41 +0,0 @@
-import type { DatabaseClient } from '$lib/database/types';
-import type { Project } from '../../domain/entities';
-import type { ProjectRepository } from '../../domain/repositories';
-
-type ProjectRow = {
- id: string;
- name: string;
- created_at?: string;
-};
-
-export class SqliteProjectRepository implements ProjectRepository {
- constructor(private readonly db: DatabaseClient) {}
-
- list(): Project[] {
- const rows = this.db.all('SELECT * FROM projects ORDER BY created_at DESC');
- return rows.map((row) => ({
- id: row.id,
- name: row.name,
- createdAt: row.created_at,
- }));
- }
-
- upsertIfMissing(project: { id: string; name: string }): void {
- this.db.run('INSERT INTO projects (id, name) VALUES (@id, @name) ON CONFLICT(id) DO NOTHING', {
- id: project.id,
- name: project.name,
- });
- }
-
- upsertManyIfMissing(projects: Array<{ id: string; name: string }>): void {
- if (!projects.length) {
- return;
- }
-
- this.db.transaction(() => {
- for (const project of projects) {
- this.upsertIfMissing(project);
- }
- });
- }
-}
diff --git a/src/routes/+layout.server.ts b/src/routes/+layout.server.ts
index 3ce3a4f..5de5c53 100644
--- a/src/routes/+layout.server.ts
+++ b/src/routes/+layout.server.ts
@@ -1,21 +1,34 @@
-import { configService, storageBackendService } from '../modules/config';
+import { projectService } from '../modules/projects';
+import { organizationService } from '../modules/organization';
+import { can } from '../modules/auth';
+// import { configService, storageBackendService } from '../modules/config';
-export async function load({ cookies, locals }) {
- const config = configService.getConfig();
- const backends = storageBackendService.list();
+export async function load({ locals }) {
+ // const config = configService.getConfig();
+ // const backends = storageBackendService.list();
- let activeBackendId = cookies.get('active_backend');
- let activeBackend = backends.find((backend) => backend.id === activeBackendId);
+ // let activeBackendId = cookies.get('active_backend');
+ // let activeBackend = backends.find((backend) => backend.id === activeBackendId);
- if (!activeBackend && backends.length > 0) {
- activeBackend = backends[0];
- activeBackendId = activeBackend.id;
- }
+ // if (!activeBackend && backends.length > 0) {
+ // activeBackend = backends[0];
+ // activeBackendId = activeBackend.id;
+ // }
+
+ const organization = await organizationService.findBySlug('gitops');
+
+ const projects =
+ locals.user && can(locals.user, 'stateiac:read')
+ ? (await projectService.listProjects()).filter((project) => project.status === 'active')
+ : [];
return {
- isConfigured: !!config && backends.length > 0,
- backends,
- activeBackendId,
+ // isConfigured: !!config && backends.length > 0,
+ isConfigured: true,
+ // backends,
+ // activeBackendId,
user: locals.user,
+ organization,
+ projects,
};
}
diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte
index a16430b..ede3a41 100644
--- a/src/routes/+layout.svelte
+++ b/src/routes/+layout.svelte
@@ -26,13 +26,24 @@
style={`--sidebar-width:${sidebarCollapsed ? '96px' : '340px'}`}
>
{#if $page.url.pathname !== '/login'}
-
-
-
-