Refactor baas control plane architecture

This commit is contained in:
Tiago Yamamoto 2025-12-27 13:49:00 -03:00
parent 870d78fb91
commit 8887ff19f2
50 changed files with 1303 additions and 1895 deletions

View file

@ -1,18 +1,5 @@
NODE_ENV=development PORT=4000
BACKEND_PORT=4000 APPWRITE_ENDPOINT=https://cloud.appwrite.io
ADMIN_API_TOKEN=change-me APPWRITE_API_KEY=replace-with-appwrite-key
DATA_DIR=./data SUPABASE_ENDPOINT=https://api.supabase.com
SUPABASE_SERVICE_KEY=replace-with-supabase-key
# Appwrite admin credentials (to create projects)
APPWRITE_ADMIN_ENDPOINT=https://cloud.appwrite.io/v1
APPWRITE_ADMIN_PROJECT_ID=console
APPWRITE_ADMIN_API_KEY=
# Defaults for new tenant projects
DEFAULT_APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
APPWRITE_DEFAULT_RUNTIME=deno-1.35
# Optional setup script target
APPWRITE_SETUP_ENDPOINT=
APPWRITE_SETUP_PROJECT_ID=
APPWRITE_SETUP_API_KEY=

View file

@ -0,0 +1,56 @@
# baas-control-plane
Control plane multi-tenant para orquestrar provedores BaaS (Appwrite, Supabase) com foco em provisioning, schema, secrets, métricas e auditoria.
## Visão geral
- Backend Node.js + TypeScript com Fastify
- Multi-tenant com isolamento lógico por tenant
- Providers plugáveis sem lógica de negócio
- Serviços centrais para provisioning, schema, secrets, finops e audit
## Arquitetura
```
/src
/core
/providers
/modules
/lib
main.ts
```
Detalhes adicionais em [docs/architecture.md](docs/architecture.md).
## Fluxo multi-tenant
1. Criar tenant (`POST /tenants`)
2. Criar projeto para o tenant (`POST /tenants/:id/projects`)
3. Provisionar projeto no provider (`POST /projects/:id/provision`)
4. Sincronizar schema (`POST /projects/:id/schema/sync`)
5. Coletar métricas (`GET /projects/:id/metrics`)
## Como adicionar um novo provider
1. Criar pasta em `src/providers/<provider>`
2. Implementar `client`, `provisioning`, `schema`, `metrics`
3. Registrar no `provider.factory.ts`
4. Adicionar variáveis em `.env.example` e no `SecretsService`
## Como subir localmente
```bash
cp .env.example .env
npm install
npm run dev
```
### Docker
```bash
docker compose up --build
```
## API mínima
- `POST /tenants`
- `GET /tenants`
- `POST /tenants/:id/projects`
- `GET /tenants/:id/projects`
- `POST /projects/:id/provision`
- `POST /projects/:id/schema/sync`
- `GET /projects/:id/metrics`
- `GET /health`

View file

@ -0,0 +1,17 @@
# Arquitetura
O `baas-control-plane` implementa um control plane modular para gerenciar múltiplos provedores BaaS de forma multi-tenant. Ele centraliza provisioning, schema, secrets, métricas e auditoria sem executar workloads de clientes.
## Camadas
- **core**: tipos e interface dos providers.
- **providers**: implementações técnicas de Appwrite e Supabase.
- **modules**: serviços de negócio (tenants, projects, provisioning, schema, secrets, finops, audit).
- **lib**: utilitários de ambiente, logger e HTTP.
## Fluxo básico
1. Tenant é criado e armazenado.
2. Projeto é criado e vinculado a um provider.
3. Provisioning aciona o provider e salva o `externalId`.
4. Schema é versionado e aplicado via provider.
5. FinOps coleta métricas normalizadas.
6. Auditoria registra eventos relevantes.

View file

@ -0,0 +1,21 @@
# Providers
Os providers implementam apenas comandos técnicos e não contêm regras de negócio.
## Interface obrigatória
- `createProject`
- `deleteProject`
- `applySchema`
- `collectMetrics`
- `rotateSecrets`
- `healthCheck`
## Implementações iniciais
- Appwrite: `src/providers/appwrite`
- Supabase: `src/providers/supabase`
## Extensão
1. Crie `src/providers/<provider>`
2. Implemente `ProviderInterface`
3. Registre no `provider.factory.ts`
4. Configure secrets no `SecretsService`

View file

@ -0,0 +1,11 @@
# Segurança
## Princípios
- Providers não acessam `.env` diretamente.
- Secrets são entregues via `SecretsService`.
- Preparado para integração com Vault/Infisical.
## Boas práticas
- Não faça hardcode de credenciais.
- Rotacione secrets via `rotateSecrets`.
- Audite eventos críticos (tenant, projeto, schema, secrets).

File diff suppressed because it is too large Load diff

View file

@ -1,24 +1,20 @@
{ {
"name": "core-backend", "name": "baas-control-plane",
"private": true, "private": true,
"version": "1.0.0", "version": "1.0.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "tsx watch src/main.ts", "dev": "tsx watch src/main.ts",
"build": "tsc", "build": "tsc",
"start": "node dist/main.js", "start": "node dist/main.js"
"setup:appwrite": "tsx src/scripts/setup-appwrite.ts"
}, },
"dependencies": { "dependencies": {
"cors": "^2.8.5", "@fastify/cors": "^9.0.1",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"express": "^4.19.2", "fastify": "^4.27.0",
"node-appwrite": "^14.1.0",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"devDependencies": { "devDependencies": {
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/node": "^20.12.12", "@types/node": "^20.12.12",
"tsx": "^4.15.7", "tsx": "^4.15.7",
"typescript": "^5.4.5" "typescript": "^5.4.5"

View file

@ -1,11 +0,0 @@
{
"provider": "appwrite-cloud",
"description": "Appwrite Cloud configuration for DevOps orchestration platform",
"endpoint": "${APPWRITE_ADMIN_ENDPOINT}",
"projectId": "${APPWRITE_ADMIN_PROJECT_ID}",
"apiKey": "${APPWRITE_ADMIN_API_KEY}",
"functions": {
"defaultRuntime": "deno-1.35",
"source": "../../appwrite-functions"
}
}

View file

@ -0,0 +1,19 @@
import { ProviderInterface } from './provider.interface.js';
import { ProviderType } from './types.js';
import { AppwriteProvider } from '../providers/appwrite/appwrite.provisioning.js';
import { SupabaseProvider } from '../providers/supabase/supabase.provisioning.js';
const providerRegistry: Record<ProviderType, () => ProviderInterface> = {
appwrite: () => new AppwriteProvider(),
supabase: () => new SupabaseProvider(),
};
export const providerFactory = {
create(type: ProviderType): ProviderInterface {
const providerBuilder = providerRegistry[type];
if (!providerBuilder) {
throw new Error(`Provider ${type} is not registered`);
}
return providerBuilder();
},
};

View file

@ -0,0 +1,10 @@
import { ProviderMetrics, ProviderProject, ProviderSecrets, SchemaDefinition } from './types.js';
export interface ProviderInterface {
createProject(name: string, secrets: ProviderSecrets): Promise<ProviderProject>;
deleteProject(externalId: string, secrets: ProviderSecrets): Promise<void>;
applySchema(externalId: string, schema: SchemaDefinition, secrets: ProviderSecrets): Promise<void>;
collectMetrics(externalId: string, secrets: ProviderSecrets): Promise<ProviderMetrics>;
rotateSecrets(externalId: string, secrets: ProviderSecrets): Promise<ProviderSecrets>;
healthCheck(secrets: ProviderSecrets): Promise<boolean>;
}

View file

@ -0,0 +1,59 @@
export type ProviderType = 'appwrite' | 'supabase';
export type TenantStatus = 'active' | 'suspended';
export type ProjectStatus = 'draft' | 'provisioning' | 'provisioned' | 'failed';
export interface Tenant {
id: string;
name: string;
plan: string;
status: TenantStatus;
createdAt: string;
updatedAt: string;
}
export interface Project {
id: string;
tenantId: string;
name: string;
provider: ProviderType;
status: ProjectStatus;
externalId?: string;
createdAt: string;
updatedAt: string;
}
export interface SchemaDefinition {
version: string;
payload: Record<string, unknown>;
}
export interface ProviderProject {
externalId: string;
dashboardUrl?: string;
metadata?: Record<string, unknown>;
}
export interface ProviderMetrics {
users: number;
storageMb: number;
requests: number;
functions: number;
capturedAt: string;
}
export interface ProviderSecrets {
endpoint: string;
apiKey: string;
projectRef?: string;
}
export interface AuditEvent {
id: string;
tenantId?: string;
projectId?: string;
action: string;
metadata?: Record<string, unknown>;
createdAt: string;
}

View file

@ -1,30 +0,0 @@
# Backend multi-tenant Appwrite
Este backend é a camada administrativa para gerenciar múltiplos projetos Appwrite.
## O que ele faz
- CRUD de tenants e projetos Appwrite (multi-tenant).
- Setup e sincronização automática de schema (database, collections, buckets e functions).
- Base para FinOps (métricas de uso) e automações DevOps.
## Rodando localmente
```bash
cd backend
npm install
cp .env.example .env
npm run dev
```
A API inicia em `http://localhost:4000`.
## Endpoints mínimos
- `POST /tenants`
- `GET /tenants`
- `POST /tenants/:id/appwrite-project`
- `GET /tenants/:id/appwrite-projects`
- `POST /appwrite/setup`
- `POST /appwrite/sync-schema`
- `GET /health`

View file

@ -1,30 +0,0 @@
# Segurança e Permissões do Appwrite
As coleções do projeto seguem uma política de Row-Level Security (RLS) para proteger os dados dos usuários e os registros de auditoria.
## Princípios gerais
- Cada documento pertence a um usuário autenticado, identificado pelo `userId` armazenado no documento.
- Operações de leitura e gravação são restritas ao proprietário do documento (role `member`).
- Logs de auditoria globais só podem ser consultados por usuários com a role `admin`.
## Regras por coleção
### `cloud_accounts`
- **Leitura**: apenas o usuário que criou o documento (role `member`).
- **Criação**: apenas usuários autenticados.
- **Atualização/Exclusão**: somente o proprietário do documento.
### `projects`
- **Leitura**: restrita ao proprietário do documento.
- **Criação**: usuários autenticados podem criar seus próprios registros.
- **Atualização/Exclusão**: apenas o proprietário do documento.
### `audit_logs`
- **Leitura**: exclusiva para usuários com role `admin` (para auditoria global).
- **Criação**: serviços e funções podem registrar ações em nome dos usuários; cada entrada mantém o `userId` responsável.
- **Atualização/Exclusão**: não permitidas para usuários finais; apenas processos administrativos podem gerenciar retenção.
## Considerações adicionais
- Tokens e chaves sensíveis (GitHub, Cloudflare) devem ser armazenados em `cloud_accounts` e nunca enviados ao frontend.
- Funções Cloud (como as de proxy) devem validar o `userId` associado ao documento antes de usar qualquer chave.
- Revogue ou rotacione chaves comprometidas removendo ou atualizando o documento correspondente na coleção.

View file

@ -1,23 +0,0 @@
# Guia rápido - Setup Appwrite (Multi-tenant)
O script `setup-appwrite.ts` aplica o schema base (database, collections, buckets e functions)
para um projeto Appwrite específico.
## Variáveis necessárias
Edite `backend/.env` e configure:
```env
APPWRITE_SETUP_ENDPOINT=https://cloud.appwrite.io/v1
APPWRITE_SETUP_PROJECT_ID=<project_id>
APPWRITE_SETUP_API_KEY=<api_key>
```
## Executar
```bash
cd backend
npm run setup:appwrite
```
O script é idempotente (pode rodar várias vezes sem duplicar recursos).

View file

@ -1,46 +0,0 @@
# Appwrite Database Schema (Base Multi-tenant)
Schema base aplicado pelo backend para novos projetos Appwrite.
## Database
- **ID**: `core-platform`
- **Nome**: Core Platform
## Collections
### tenants
- `name` (string)
- `slug` (string)
- `status` (enum: active | inactive)
- `createdAt` (datetime)
### projects
- `tenantId` (string)
- `name` (string)
- `endpoint` (string)
- `projectId` (string)
- `createdAt` (datetime)
### finops_usage
- `tenantId` (string)
- `users` (integer)
- `documents` (integer)
- `storageBytes` (integer)
- `functionRuns` (integer)
- `capturedAt` (datetime)
### audit_logs
- `event` (string)
- `actor` (string)
- `timestamp` (datetime)
## Buckets
- `tenant-assets` (storage base por tenant)
## Functions
- `hello-world`
- `sync-github`
- `check-cloudflare-status`

View file

@ -1,184 +0,0 @@
import {
Client,
Databases,
Functions,
Storage,
ID,
Runtime,
} from 'node-appwrite';
import { appwriteSchema } from '../modules/projects/appwriteSchema.js';
import { logger } from './logger.js';
export type AppwriteConnection = {
endpoint: string;
projectId: string;
apiKey: string;
};
export type ApplySchemaResult = {
databaseId: string;
collections: string[];
buckets: string[];
functions: string[];
};
export const createAppwriteClient = ({ endpoint, projectId, apiKey }: AppwriteConnection) =>
new Client().setEndpoint(endpoint).setProject(projectId).setKey(apiKey);
const isConflict = (error: unknown) =>
typeof error === 'object' && error !== null && 'code' in error && (error as { code?: number }).code === 409;
export const applySchema = async (
connection: AppwriteConnection,
options?: { logPrefix?: string }
): Promise<ApplySchemaResult> => {
const client = createAppwriteClient(connection);
const databases = new Databases(client);
const storage = new Storage(client);
const functions = new Functions(client);
const logPrefix = options?.logPrefix ?? 'appwrite';
logger.info(`[${logPrefix}] Applying Appwrite schema`, {
endpoint: connection.endpoint,
projectId: connection.projectId,
});
const { database } = appwriteSchema;
try {
await databases.create(database.id, database.name, database.enabled);
logger.info(`[${logPrefix}] Database created`, { databaseId: database.id });
} catch (error) {
if (!isConflict(error)) {
throw error;
}
logger.info(`[${logPrefix}] Database already exists`, { databaseId: database.id });
}
for (const collection of appwriteSchema.collections) {
try {
await databases.createCollection(database.id, collection.id, collection.name, collection.permissions);
logger.info(`[${logPrefix}] Collection created`, { collectionId: collection.id });
} catch (error) {
if (!isConflict(error)) {
throw error;
}
logger.info(`[${logPrefix}] Collection already exists`, { collectionId: collection.id });
}
for (const attribute of collection.attributes) {
try {
switch (attribute.type) {
case 'string':
await databases.createStringAttribute(
database.id,
collection.id,
attribute.key,
attribute.size,
attribute.required,
attribute.array ?? false
);
break;
case 'enum':
await databases.createEnumAttribute(
database.id,
collection.id,
attribute.key,
attribute.elements,
attribute.required
);
break;
case 'integer':
await databases.createIntegerAttribute(
database.id,
collection.id,
attribute.key,
attribute.required
);
break;
case 'datetime':
await databases.createDatetimeAttribute(
database.id,
collection.id,
attribute.key,
attribute.required
);
break;
case 'boolean':
await databases.createBooleanAttribute(
database.id,
collection.id,
attribute.key,
attribute.required
);
break;
case 'url':
await databases.createUrlAttribute(
database.id,
collection.id,
attribute.key,
attribute.required
);
break;
default:
break;
}
logger.info(`[${logPrefix}] Attribute ensured`, {
collectionId: collection.id,
attribute: attribute.key,
});
} catch (error) {
if (!isConflict(error)) {
throw error;
}
}
}
}
for (const bucket of appwriteSchema.buckets) {
try {
await storage.createBucket(bucket.id, bucket.name, bucket.permissions, bucket.fileSecurity ?? false);
logger.info(`[${logPrefix}] Bucket created`, { bucketId: bucket.id });
} catch (error) {
if (!isConflict(error)) {
throw error;
}
logger.info(`[${logPrefix}] Bucket already exists`, { bucketId: bucket.id });
}
}
for (const func of appwriteSchema.functions) {
try {
await functions.get(func.id);
logger.info(`[${logPrefix}] Function already exists`, { functionId: func.id });
} catch (error) {
const code = typeof error === 'object' && error !== null && 'code' in error ? (error as { code?: number }).code : null;
if (code !== 404 && code !== 409) {
throw error;
}
await functions.create(func.id, func.name, func.runtime as Runtime, func.execute);
logger.info(`[${logPrefix}] Function created`, { functionId: func.id });
}
}
return {
databaseId: database.id,
collections: appwriteSchema.collections.map((collection) => collection.id),
buckets: appwriteSchema.buckets.map((bucket) => bucket.id),
functions: appwriteSchema.functions.map((func) => func.id),
};
};
export const createProject = async (
connection: AppwriteConnection,
projectName: string
): Promise<{ projectId: string }> => {
const client = createAppwriteClient(connection);
const { Projects } = await import('node-appwrite');
const projects = new Projects(client);
const projectId = ID.unique();
await projects.create(projectId, projectName);
return { projectId };
};

View file

@ -1,30 +1,22 @@
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import path from 'path'; import { z } from 'zod';
const envFile = process.env.ENV_FILE || path.resolve(process.cwd(), '.env'); dotenv.config();
dotenv.config({ path: envFile }); const envSchema = z.object({
PORT: z.string().default('4000'),
APPWRITE_ENDPOINT: z.string().default('https://cloud.appwrite.io'),
APPWRITE_API_KEY: z.string().default('appwrite-api-key'),
SUPABASE_ENDPOINT: z.string().default('https://api.supabase.com'),
SUPABASE_SERVICE_KEY: z.string().default('supabase-service-key'),
});
const numberFromEnv = (value: string | undefined, fallback: number) => { const parsed = envSchema.parse(process.env);
if (!value) {
return fallback;
}
const parsed = Number.parseInt(value, 10);
return Number.isNaN(parsed) ? fallback : parsed;
};
export const env = { export const env = {
nodeEnv: process.env.NODE_ENV ?? 'development', port: Number(parsed.PORT),
port: numberFromEnv(process.env.BACKEND_PORT, 4000), appwriteEndpoint: parsed.APPWRITE_ENDPOINT,
adminToken: process.env.ADMIN_API_TOKEN ?? '', appwriteApiKey: parsed.APPWRITE_API_KEY,
dataDir: process.env.DATA_DIR ?? 'data', supabaseEndpoint: parsed.SUPABASE_ENDPOINT,
appwriteAdmin: { supabaseServiceKey: parsed.SUPABASE_SERVICE_KEY,
endpoint: process.env.APPWRITE_ADMIN_ENDPOINT ?? '',
projectId: process.env.APPWRITE_ADMIN_PROJECT_ID ?? '',
apiKey: process.env.APPWRITE_ADMIN_API_KEY ?? '',
},
defaults: {
appwriteEndpoint: process.env.DEFAULT_APPWRITE_ENDPOINT ?? process.env.APPWRITE_DEFAULT_ENDPOINT ?? '',
functionRuntime: process.env.APPWRITE_DEFAULT_RUNTIME ?? 'deno-1.35',
},
}; };

View file

@ -0,0 +1,24 @@
export const http = {
async get<T>(url: string, options?: RequestInit): Promise<T> {
const response = await fetch(url, { ...options, method: 'GET' });
if (!response.ok) {
throw new Error(`HTTP GET failed with status ${response.status}`);
}
return response.json() as Promise<T>;
},
async post<T>(url: string, body?: unknown, options?: RequestInit): Promise<T> {
const response = await fetch(url, {
...options,
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(options?.headers ?? {}),
},
body: body ? JSON.stringify(body) : undefined,
});
if (!response.ok) {
throw new Error(`HTTP POST failed with status ${response.status}`);
}
return response.json() as Promise<T>;
},
};

View file

@ -1,15 +1,34 @@
export type LogMeta = Record<string, unknown> | undefined; type LogPayload = Record<string, unknown>;
const formatMeta = (meta?: LogMeta) => (meta ? ` ${JSON.stringify(meta)}` : ''); const log = (level: 'info' | 'error' | 'warn', message: string, payload?: LogPayload) => {
const entry = {
level,
message,
timestamp: new Date().toISOString(),
...payload,
};
if (level === 'error') {
console.error(entry);
return;
}
if (level === 'warn') {
console.warn(entry);
return;
}
console.log(entry);
};
export const logger = { export const logger = {
info(message: string, meta?: LogMeta) { info(message: string, payload?: LogPayload) {
console.log(`[INFO] ${message}${formatMeta(meta)}`); log('info', message, payload);
}, },
warn(message: string, meta?: LogMeta) { warn(message: string, payload?: LogPayload) {
console.warn(`[WARN] ${message}${formatMeta(meta)}`); log('warn', message, payload);
}, },
error(message: string, meta?: LogMeta) { error(message: string, payload?: LogPayload) {
console.error(`[ERROR] ${message}${formatMeta(meta)}`); log('error', message, payload);
}, },
}; };

View file

@ -0,0 +1,29 @@
import { promises as fs } from 'fs';
import path from 'path';
const dataDir = path.resolve('data');
const ensureDir = async () => {
await fs.mkdir(dataDir, { recursive: true });
};
const filePath = (file: string) => path.join(dataDir, file);
export const storage = {
async readCollection<T>(file: string): Promise<T[]> {
await ensureDir();
try {
const content = await fs.readFile(filePath(file), 'utf-8');
return JSON.parse(content) as T[];
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return [];
}
throw error;
}
},
async writeCollection<T>(file: string, data: T[]): Promise<void> {
await ensureDir();
await fs.writeFile(filePath(file), JSON.stringify(data, null, 2));
},
};

View file

@ -1,34 +1,57 @@
import express from 'express'; import Fastify from 'fastify';
import cors from 'cors'; import cors from '@fastify/cors';
import { env } from './lib/env.js'; import { env } from './lib/env.js';
import { logger } from './lib/logger.js'; import { logger } from './lib/logger.js';
import { requireAdminToken } from './modules/auth/auth.middleware.js'; import { TenantsService } from './modules/tenants/tenants.service.js';
import { tenantsRouter } from './modules/tenants/tenants.routes.js'; import { ProjectsService } from './modules/projects/projects.service.js';
import { appwriteRouter } from './modules/projects/appwrite.routes.js'; import { ProvisioningService } from './modules/provisioning/provisioning.service.js';
import { SchemaService } from './modules/schema/schema.service.js';
import { SchemaVersioning } from './modules/schema/schema.versioning.js';
import { SecretsService } from './modules/secrets/secrets.service.js';
import { AuditService } from './modules/audit/audit.service.js';
import { FinopsCollector } from './modules/finops/finops.collector.js';
import { registerTenantsController } from './modules/tenants/tenants.controller.js';
import { registerProjectsController } from './modules/projects/projects.controller.js';
import { providerFactory } from './core/provider.factory.js';
const app = express(); const app = Fastify({ logger: false });
app.use(cors()); await app.register(cors, { origin: true });
app.use(express.json({ limit: '1mb' }));
app.get('/health', (_request, response) => { const tenantsService = new TenantsService();
response.json({ status: 'ok' }); const projectsService = new ProjectsService();
const secretsService = new SecretsService();
const auditService = new AuditService();
const provisioningService = new ProvisioningService(projectsService, secretsService);
const schemaService = new SchemaService(projectsService, secretsService, new SchemaVersioning());
const finopsCollector = new FinopsCollector(projectsService, secretsService);
app.get('/health', async () => {
const appwrite = providerFactory.create('appwrite');
const supabase = providerFactory.create('supabase');
const [appwriteHealthy, supabaseHealthy] = await Promise.all([
appwrite.healthCheck(await secretsService.getProviderSecrets('appwrite', 'system')),
supabase.healthCheck(await secretsService.getProviderSecrets('supabase', 'system')),
]);
return {
status: 'ok',
providers: {
appwrite: appwriteHealthy,
supabase: supabaseHealthy,
},
};
}); });
app.use(requireAdminToken); registerTenantsController(app, tenantsService, auditService);
registerProjectsController(app, projectsService, provisioningService, schemaService, auditService, finopsCollector);
app.use('/tenants', tenantsRouter); app.setErrorHandler((error, _request, reply) => {
app.use('/appwrite', appwriteRouter);
app.use((error: Error, _request: express.Request, response: express.Response, _next: express.NextFunction) => {
logger.error('Request failed', { message: error.message }); logger.error('Request failed', { message: error.message });
reply.status(400).send({ error: error.message });
const status = error.message.includes('not found') ? 404 : 400;
response.status(status).json({
error: error.message,
});
}); });
app.listen(env.port, () => { app.listen({ port: env.port, host: '0.0.0.0' }).then(() => {
logger.info(`Backend listening on http://localhost:${env.port}`); logger.info(`baas-control-plane listening on http://localhost:${env.port}`);
}); });

View file

@ -0,0 +1,18 @@
import { storage } from '../../lib/storage.js';
import { AuditEvent } from '../../core/types.js';
const AUDIT_FILE = 'audit-events.json';
export class AuditService {
async record(event: Omit<AuditEvent, 'id' | 'createdAt'>): Promise<AuditEvent> {
const events = await storage.readCollection<AuditEvent>(AUDIT_FILE);
const entry: AuditEvent = {
id: crypto.randomUUID(),
createdAt: new Date().toISOString(),
...event,
};
events.push(entry);
await storage.writeCollection(AUDIT_FILE, events);
return entry;
}
}

View file

@ -1,22 +0,0 @@
import type { NextFunction, Request, Response } from 'express';
import { env } from '../../lib/env.js';
export const requireAdminToken = (request: Request, response: Response, next: NextFunction) => {
if (!env.adminToken) {
return next();
}
const header = request.headers.authorization;
const fallbackToken = request.headers['x-admin-token'];
const tokenValue = Array.isArray(fallbackToken) ? fallbackToken[0] : fallbackToken;
const token = header?.startsWith('Bearer ') ? header.slice(7) : tokenValue;
if (token !== env.adminToken) {
return response.status(401).json({
error: 'Unauthorized',
message: 'Invalid admin token',
});
}
return next();
};

View file

@ -0,0 +1,22 @@
import { providerFactory } from '../../core/provider.factory.js';
import { ProjectsService } from '../projects/projects.service.js';
import { SecretsService } from '../secrets/secrets.service.js';
import { ProviderMetrics } from '../../core/types.js';
export class FinopsCollector {
constructor(
private readonly projectsService: ProjectsService,
private readonly secretsService: SecretsService,
) {}
async collectForProject(projectId: string): Promise<ProviderMetrics> {
const project = await this.projectsService.getProject(projectId);
if (!project || !project.externalId) {
throw new Error('Project not provisioned');
}
const provider = providerFactory.create(project.provider);
const secrets = await this.secretsService.getProviderSecrets(project.provider, project.tenantId);
return provider.collectMetrics(project.externalId, secrets);
}
}

View file

@ -1,24 +0,0 @@
export type FinopsSnapshot = {
users: number;
documents: number;
storageBytes: number;
functionRuns: number;
capturedAt: string;
};
export type FinopsSummary = FinopsSnapshot & {
lastSyncedAt?: string;
};
export const createEmptyFinops = (): FinopsSummary => ({
users: 0,
documents: 0,
storageBytes: 0,
functionRuns: 0,
capturedAt: new Date().toISOString(),
});
export const recordSnapshot = (summary: FinopsSummary, snapshot: FinopsSnapshot): FinopsSummary => ({
...snapshot,
lastSyncedAt: new Date().toISOString(),
});

View file

@ -1,36 +0,0 @@
import { Router } from 'express';
import { z } from 'zod';
import { syncSchemaForProject } from './appwrite.service.js';
const schemaRequest = z.object({
tenantId: z.string().min(1),
projectRef: z.string().optional(),
});
export const appwriteRouter = Router();
appwriteRouter.post('/setup', async (request, response, next) => {
try {
const payload = schemaRequest.parse(request.body);
const result = await syncSchemaForProject(payload.tenantId, payload.projectRef);
response.status(200).json({
message: 'Appwrite setup applied',
result,
});
} catch (error) {
next(error);
}
});
appwriteRouter.post('/sync-schema', async (request, response, next) => {
try {
const payload = schemaRequest.parse(request.body);
const result = await syncSchemaForProject(payload.tenantId, payload.projectRef);
response.status(200).json({
message: 'Schema synchronized',
result,
});
} catch (error) {
next(error);
}
});

View file

@ -1,56 +0,0 @@
import { applySchema } from '../../lib/appwrite.js';
import { loadTenants, saveTenants } from '../tenants/tenants.store.js';
import type { AppwriteProject } from '../tenants/tenants.types.js';
import { logger } from '../../lib/logger.js';
export const resolveProject = async (tenantId: string, projectRef?: string): Promise<AppwriteProject> => {
const tenants = await loadTenants();
const tenant = tenants.find((item) => item.id === tenantId);
if (!tenant) {
throw new Error('Tenant not found');
}
if (!projectRef) {
if (tenant.appwriteProjects.length === 1) {
return tenant.appwriteProjects[0];
}
throw new Error('Multiple projects found. Provide projectRef.');
}
const project = tenant.appwriteProjects.find(
(item) => item.id === projectRef || item.projectId === projectRef
);
if (!project) {
throw new Error('Project not found');
}
return project;
};
export const syncSchemaForProject = async (tenantId: string, projectRef?: string) => {
const project = await resolveProject(tenantId, projectRef);
const result = await applySchema({
endpoint: project.endpoint,
projectId: project.projectId,
apiKey: project.apiKey,
});
project.updatedAt = new Date().toISOString();
const tenants = await loadTenants();
const tenant = tenants.find((item) => item.id === tenantId);
if (tenant) {
tenant.updatedAt = project.updatedAt;
await saveTenants(tenants);
}
logger.info('Schema synced for project', {
tenantId,
projectId: project.projectId,
});
return result;
};

View file

@ -1,179 +0,0 @@
import { Permission, Role, Runtime } from 'node-appwrite';
import { env } from '../../lib/env.js';
export type AttributeDefinition =
| {
type: 'string';
key: string;
size: number;
required: boolean;
array?: boolean;
}
| {
type: 'enum';
key: string;
elements: string[];
required: boolean;
}
| {
type: 'integer';
key: string;
required: boolean;
}
| {
type: 'datetime';
key: string;
required: boolean;
}
| {
type: 'boolean';
key: string;
required: boolean;
}
| {
type: 'url';
key: string;
required: boolean;
};
export type CollectionSchema = {
id: string;
name: string;
permissions: string[];
attributes: AttributeDefinition[];
};
export type BucketSchema = {
id: string;
name: string;
permissions: string[];
fileSecurity?: boolean;
};
export type FunctionSchema = {
id: string;
name: string;
runtime: Runtime;
execute: string[];
};
export type AppwriteSchema = {
database: {
id: string;
name: string;
enabled: boolean;
};
collections: CollectionSchema[];
buckets: BucketSchema[];
functions: FunctionSchema[];
};
const runtime = (Object.values(Runtime) as string[]).includes(env.defaults.functionRuntime)
? (env.defaults.functionRuntime as Runtime)
: Runtime.Deno135;
export const appwriteSchema: AppwriteSchema = {
database: {
id: 'core-platform',
name: 'Core Platform',
enabled: true,
},
collections: [
{
id: 'tenants',
name: 'Tenants',
permissions: [
Permission.read(Role.any()),
Permission.create(Role.team('admins')),
Permission.update(Role.team('admins')),
Permission.delete(Role.team('admins')),
],
attributes: [
{ type: 'string', key: 'name', size: 255, required: true },
{ type: 'string', key: 'slug', size: 120, required: true },
{ type: 'enum', key: 'status', elements: ['active', 'inactive'], required: true },
{ type: 'datetime', key: 'createdAt', required: true },
],
},
{
id: 'projects',
name: 'Projects',
permissions: [
Permission.read(Role.team('admins')),
Permission.create(Role.team('admins')),
Permission.update(Role.team('admins')),
Permission.delete(Role.team('admins')),
],
attributes: [
{ type: 'string', key: 'tenantId', size: 80, required: true },
{ type: 'string', key: 'name', size: 255, required: true },
{ type: 'string', key: 'endpoint', size: 255, required: true },
{ type: 'string', key: 'projectId', size: 255, required: true },
{ type: 'datetime', key: 'createdAt', required: true },
],
},
{
id: 'finops_usage',
name: 'FinOps Usage',
permissions: [
Permission.read(Role.team('admins')),
Permission.create(Role.team('admins')),
Permission.update(Role.team('admins')),
],
attributes: [
{ type: 'string', key: 'tenantId', size: 80, required: true },
{ type: 'integer', key: 'users', required: true },
{ type: 'integer', key: 'documents', required: true },
{ type: 'integer', key: 'storageBytes', required: true },
{ type: 'integer', key: 'functionRuns', required: true },
{ type: 'datetime', key: 'capturedAt', required: true },
],
},
{
id: 'audit_logs',
name: 'Audit Logs',
permissions: [
Permission.read(Role.team('admins')),
Permission.create(Role.team('admins')),
],
attributes: [
{ type: 'string', key: 'event', size: 500, required: true },
{ type: 'string', key: 'actor', size: 255, required: true },
{ type: 'datetime', key: 'timestamp', required: true },
],
},
],
buckets: [
{
id: 'tenant-assets',
name: 'Tenant Assets',
permissions: [
Permission.read(Role.team('admins')),
Permission.create(Role.team('admins')),
Permission.update(Role.team('admins')),
Permission.delete(Role.team('admins')),
],
fileSecurity: true,
},
],
functions: [
{
id: 'hello-world',
name: 'Hello World',
runtime,
execute: [Role.any()],
},
{
id: 'sync-github',
name: 'Sync GitHub',
runtime,
execute: [Role.team('admins')],
},
{
id: 'check-cloudflare-status',
name: 'Check Cloudflare Status',
runtime,
execute: [Role.team('admins')],
},
],
};

View file

@ -0,0 +1,75 @@
import { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { ProjectsService } from './projects.service.js';
import { ProvisioningService } from '../provisioning/provisioning.service.js';
import { SchemaService } from '../schema/schema.service.js';
import { AuditService } from '../audit/audit.service.js';
import { FinopsCollector } from '../finops/finops.collector.js';
const projectSchema = z.object({
name: z.string().min(2),
provider: z.enum(['appwrite', 'supabase']),
});
const schemaSyncPayload = z.object({
version: z.string().min(1),
payload: z.record(z.unknown()),
});
export const registerProjectsController = (
app: FastifyInstance,
projectsService: ProjectsService,
provisioningService: ProvisioningService,
schemaService: SchemaService,
auditService: AuditService,
finopsCollector: FinopsCollector,
) => {
app.get('/tenants/:tenantId/projects', async (request) => {
const { tenantId } = request.params as { tenantId: string };
return projectsService.listProjectsForTenant(tenantId);
});
app.post('/tenants/:tenantId/projects', async (request, reply) => {
const { tenantId } = request.params as { tenantId: string };
const payload = projectSchema.parse(request.body);
const project = await projectsService.createProject(tenantId, payload);
await auditService.record({
tenantId,
projectId: project.id,
action: 'project.created',
metadata: { provider: project.provider },
});
reply.code(201);
return project;
});
app.post('/projects/:projectId/provision', async (request) => {
const { projectId } = request.params as { projectId: string };
const result = await provisioningService.provisionProject(projectId);
await auditService.record({
projectId,
tenantId: result.project.tenantId,
action: 'project.provisioned',
metadata: { provider: result.project.provider, externalId: result.project.externalId },
});
return result;
});
app.post('/projects/:projectId/schema/sync', async (request) => {
const { projectId } = request.params as { projectId: string };
const payload = schemaSyncPayload.parse(request.body);
const result = await schemaService.syncSchema(projectId, payload);
await auditService.record({
projectId,
tenantId: result.project.tenantId,
action: 'schema.applied',
metadata: { version: payload.version },
});
return result;
});
app.get('/projects/:projectId/metrics', async (request) => {
const { projectId } = request.params as { projectId: string };
return finopsCollector.collectForProject(projectId);
});
};

View file

@ -0,0 +1,3 @@
import { Project } from '../../core/types.js';
export type ProjectEntity = Project;

View file

@ -0,0 +1,49 @@
import { storage } from '../../lib/storage.js';
import { Project, ProviderType } from '../../core/types.js';
const PROJECTS_FILE = 'projects.json';
export class ProjectsService {
async listProjectsForTenant(tenantId: string): Promise<Project[]> {
const projects = await storage.readCollection<Project>(PROJECTS_FILE);
return projects.filter((project) => project.tenantId === tenantId);
}
async getProject(projectId: string): Promise<Project | undefined> {
const projects = await storage.readCollection<Project>(PROJECTS_FILE);
return projects.find((project) => project.id === projectId);
}
async createProject(tenantId: string, input: { name: string; provider: ProviderType }): Promise<Project> {
const projects = await storage.readCollection<Project>(PROJECTS_FILE);
const now = new Date().toISOString();
const project: Project = {
id: crypto.randomUUID(),
tenantId,
name: input.name,
provider: input.provider,
status: 'draft',
createdAt: now,
updatedAt: now,
};
projects.push(project);
await storage.writeCollection(PROJECTS_FILE, projects);
return project;
}
async updateProject(projectId: string, changes: Partial<Project>): Promise<Project> {
const projects = await storage.readCollection<Project>(PROJECTS_FILE);
const index = projects.findIndex((project) => project.id === projectId);
if (index === -1) {
throw new Error('Project not found');
}
const updated = {
...projects[index],
...changes,
updatedAt: new Date().toISOString(),
};
projects[index] = updated;
await storage.writeCollection(PROJECTS_FILE, projects);
return updated;
}
}

View file

@ -0,0 +1,29 @@
import { ProjectsService } from '../projects/projects.service.js';
import { providerFactory } from '../../core/provider.factory.js';
import { SecretsService } from '../secrets/secrets.service.js';
import { Project } from '../../core/types.js';
export class ProvisioningService {
constructor(
private readonly projectsService: ProjectsService,
private readonly secretsService: SecretsService,
) {}
async provisionProject(projectId: string): Promise<{ project: Project }> {
const project = await this.projectsService.getProject(projectId);
if (!project) {
throw new Error('Project not found');
}
const provider = providerFactory.create(project.provider);
const secrets = await this.secretsService.getProviderSecrets(project.provider, project.tenantId);
const created = await provider.createProject(project.name, secrets);
const updated = await this.projectsService.updateProject(projectId, {
status: 'provisioned',
externalId: created.externalId,
});
return { project: updated };
}
}

View file

@ -0,0 +1,27 @@
import { SchemaDefinition } from '../../core/types.js';
import { ProjectsService } from '../projects/projects.service.js';
import { providerFactory } from '../../core/provider.factory.js';
import { SecretsService } from '../secrets/secrets.service.js';
import { SchemaVersioning } from './schema.versioning.js';
export class SchemaService {
constructor(
private readonly projectsService: ProjectsService,
private readonly secretsService: SecretsService,
private readonly versioning: SchemaVersioning,
) {}
async syncSchema(projectId: string, schema: SchemaDefinition): Promise<{ project: { id: string; tenantId: string } }> {
const project = await this.projectsService.getProject(projectId);
if (!project || !project.externalId) {
throw new Error('Project not provisioned');
}
const provider = providerFactory.create(project.provider);
const secrets = await this.secretsService.getProviderSecrets(project.provider, project.tenantId);
await provider.applySchema(project.externalId, schema, secrets);
await this.versioning.addVersion(projectId, schema);
return { project: { id: project.id, tenantId: project.tenantId } };
}
}

View file

@ -0,0 +1,28 @@
import { storage } from '../../lib/storage.js';
import { SchemaDefinition } from '../../core/types.js';
const SCHEMA_FILE = 'schema-versions.json';
interface SchemaVersionRecord {
projectId: string;
versions: SchemaDefinition[];
}
export class SchemaVersioning {
async listVersions(projectId: string): Promise<SchemaDefinition[]> {
const records = await storage.readCollection<SchemaVersionRecord>(SCHEMA_FILE);
const record = records.find((item) => item.projectId === projectId);
return record?.versions ?? [];
}
async addVersion(projectId: string, schema: SchemaDefinition): Promise<void> {
const records = await storage.readCollection<SchemaVersionRecord>(SCHEMA_FILE);
const existing = records.find((item) => item.projectId === projectId);
if (existing) {
existing.versions.push(schema);
} else {
records.push({ projectId, versions: [schema] });
}
await storage.writeCollection(SCHEMA_FILE, records);
}
}

View file

@ -0,0 +1,52 @@
import { env } from '../../lib/env.js';
import { ProviderSecrets, ProviderType } from '../../core/types.js';
import { storage } from '../../lib/storage.js';
const SECRETS_FILE = 'provider-secrets.json';
interface SecretsRecord {
tenantId: string;
provider: ProviderType;
secrets: ProviderSecrets;
}
export class SecretsService {
async getProviderSecrets(provider: ProviderType, tenantId: string): Promise<ProviderSecrets> {
const records = await storage.readCollection<SecretsRecord>(SECRETS_FILE);
const record = records.find((item) => item.tenantId === tenantId && item.provider === provider);
if (record) {
return record.secrets;
}
const defaults: Record<ProviderType, ProviderSecrets> = {
appwrite: {
endpoint: env.appwriteEndpoint,
apiKey: env.appwriteApiKey,
},
supabase: {
endpoint: env.supabaseEndpoint,
apiKey: env.supabaseServiceKey,
},
};
return defaults[provider];
}
async rotateProviderSecrets(
provider: ProviderType,
tenantId: string,
secrets: ProviderSecrets,
): Promise<void> {
const records = await storage.readCollection<SecretsRecord>(SECRETS_FILE);
const existing = records.find((item) => item.tenantId === tenantId && item.provider === provider);
if (existing) {
existing.secrets = secrets;
} else {
records.push({ tenantId, provider, secrets });
}
await storage.writeCollection(SECRETS_FILE, records);
}
}

View file

@ -0,0 +1,30 @@
import { FastifyInstance } from 'fastify';
import { z } from 'zod';
import { TenantsService } from './tenants.service.js';
import { AuditService } from '../audit/audit.service.js';
const tenantSchema = z.object({
name: z.string().min(2),
plan: z.string().optional(),
status: z.enum(['active', 'suspended']).optional(),
});
export const registerTenantsController = (
app: FastifyInstance,
tenantsService: TenantsService,
auditService: AuditService,
) => {
app.get('/tenants', async () => tenantsService.listTenants());
app.post('/tenants', async (request, reply) => {
const payload = tenantSchema.parse(request.body);
const tenant = await tenantsService.createTenant(payload);
await auditService.record({
tenantId: tenant.id,
action: 'tenant.created',
metadata: { name: tenant.name, plan: tenant.plan },
});
reply.code(201);
return tenant;
});
};

View file

@ -0,0 +1,3 @@
import { Tenant } from '../../core/types.js';
export type TenantEntity = Tenant;

View file

@ -1,85 +0,0 @@
import { Router } from 'express';
import { z } from 'zod';
import {
addAppwriteProject,
createTenant,
listTenantProjects,
listTenants,
redactProject,
removeTenantProject,
updateTenantProject,
} from './tenants.service.js';
const tenantSchema = z.object({
name: z.string().min(2),
slug: z.string().optional(),
});
const projectSchema = z.object({
name: z.string().min(2),
endpoint: z.string().url(),
projectId: z.string().optional(),
apiKey: z.string().min(10),
createProject: z.boolean().optional(),
});
const projectUpdateSchema = projectSchema.partial();
export const tenantsRouter = Router();
tenantsRouter.post('/', async (request, response, next) => {
try {
const payload = tenantSchema.parse(request.body);
const tenant = await createTenant(payload);
response.status(201).json(tenant);
} catch (error) {
next(error);
}
});
tenantsRouter.get('/', async (_request, response, next) => {
try {
const tenants = await listTenants();
response.json(tenants);
} catch (error) {
next(error);
}
});
tenantsRouter.post('/:id/appwrite-project', async (request, response, next) => {
try {
const payload = projectSchema.parse(request.body);
const project = await addAppwriteProject(request.params.id, payload);
response.status(201).json(redactProject(project));
} catch (error) {
next(error);
}
});
tenantsRouter.get('/:id/appwrite-projects', async (request, response, next) => {
try {
const projects = await listTenantProjects(request.params.id);
response.json(projects);
} catch (error) {
next(error);
}
});
tenantsRouter.patch('/:id/appwrite-projects/:projectRef', async (request, response, next) => {
try {
const payload = projectUpdateSchema.parse(request.body);
const project = await updateTenantProject(request.params.id, request.params.projectRef, payload);
response.json(redactProject(project));
} catch (error) {
next(error);
}
});
tenantsRouter.delete('/:id/appwrite-projects/:projectRef', async (request, response, next) => {
try {
await removeTenantProject(request.params.id, request.params.projectRef);
response.status(204).send();
} catch (error) {
next(error);
}
});

View file

@ -1,165 +1,33 @@
import crypto from 'crypto'; import { storage } from '../../lib/storage.js';
import { createEmptyFinops } from '../finops/finops.service.js'; import { logger } from '../../lib/logger.js';
import { loadTenants, saveTenants } from './tenants.store.js'; import { Tenant, TenantStatus } from '../../core/types.js';
import type { AppwriteProject, AppwriteProjectInput, Tenant, TenantInput } from './tenants.types.js';
import { env } from '../../lib/env.js';
import { createProject } from '../../lib/appwrite.js';
const slugify = (name: string) => const TENANTS_FILE = 'tenants.json';
name
.toLowerCase()
.normalize('NFD')
.replace(/[^\w\s-]/g, '')
.trim()
.replace(/[\s_-]+/g, '-')
.replace(/^-+|-+$/g, '');
export const redactProject = (project: AppwriteProject) => ({ export class TenantsService {
...project, async listTenants(): Promise<Tenant[]> {
apiKey: project.apiKey ? `${project.apiKey.slice(0, 4)}****${project.apiKey.slice(-4)}` : '', return storage.readCollection<Tenant>(TENANTS_FILE);
});
export const listTenants = async () => {
const tenants = await loadTenants();
return tenants.map((tenant) => ({
...tenant,
appwriteProjects: tenant.appwriteProjects.map(redactProject),
}));
};
export const createTenant = async (input: TenantInput): Promise<Tenant> => {
const tenants = await loadTenants();
const slug = input.slug ? slugify(input.slug) : slugify(input.name);
if (tenants.some((tenant) => tenant.slug === slug)) {
throw new Error('Tenant slug already exists');
} }
const now = new Date().toISOString(); async getTenant(id: string): Promise<Tenant | undefined> {
const tenant: Tenant = { const tenants = await storage.readCollection<Tenant>(TENANTS_FILE);
id: crypto.randomUUID(), return tenants.find((tenant) => tenant.id === id);
name: input.name,
slug,
status: 'active',
createdAt: now,
updatedAt: now,
appwriteProjects: [],
finops: createEmptyFinops(),
};
tenants.push(tenant);
await saveTenants(tenants);
return tenant;
};
export const addAppwriteProject = async (tenantId: string, input: AppwriteProjectInput): Promise<AppwriteProject> => {
const tenants = await loadTenants();
const tenant = tenants.find((item) => item.id === tenantId);
if (!tenant) {
throw new Error('Tenant not found');
} }
let projectId = input.projectId; async createTenant(input: { name: string; plan?: string; status?: TenantStatus }): Promise<Tenant> {
const tenants = await storage.readCollection<Tenant>(TENANTS_FILE);
if (input.createProject) { const now = new Date().toISOString();
if (!env.appwriteAdmin.endpoint || !env.appwriteAdmin.apiKey || !env.appwriteAdmin.projectId) { const tenant: Tenant = {
throw new Error('Missing Appwrite admin credentials to create projects'); id: crypto.randomUUID(),
} name: input.name,
plan: input.plan ?? 'standard',
const created = await createProject( status: input.status ?? 'active',
{ createdAt: now,
endpoint: env.appwriteAdmin.endpoint, updatedAt: now,
projectId: env.appwriteAdmin.projectId, };
apiKey: env.appwriteAdmin.apiKey, tenants.push(tenant);
}, await storage.writeCollection(TENANTS_FILE, tenants);
input.name logger.info('Tenant created', { tenantId: tenant.id });
); return tenant;
projectId = created.projectId;
} }
}
if (!projectId) {
throw new Error('projectId is required when createProject is false');
}
const now = new Date().toISOString();
const project: AppwriteProject = {
id: crypto.randomUUID(),
name: input.name,
endpoint: input.endpoint,
projectId,
apiKey: input.apiKey,
createdAt: now,
updatedAt: now,
};
tenant.appwriteProjects.push(project);
tenant.updatedAt = now;
await saveTenants(tenants);
return project;
};
export const listTenantProjects = async (tenantId: string) => {
const tenants = await loadTenants();
const tenant = tenants.find((item) => item.id === tenantId);
if (!tenant) {
throw new Error('Tenant not found');
}
return tenant.appwriteProjects.map(redactProject);
};
export const updateTenantProject = async (
tenantId: string,
projectRef: string,
updates: Partial<AppwriteProjectInput>
): Promise<AppwriteProject> => {
const tenants = await loadTenants();
const tenant = tenants.find((item) => item.id === tenantId);
if (!tenant) {
throw new Error('Tenant not found');
}
const project = tenant.appwriteProjects.find((item) => item.id === projectRef || item.projectId === projectRef);
if (!project) {
throw new Error('Project not found');
}
project.name = updates.name ?? project.name;
project.endpoint = updates.endpoint ?? project.endpoint;
project.projectId = updates.projectId ?? project.projectId;
project.apiKey = updates.apiKey ?? project.apiKey;
project.updatedAt = new Date().toISOString();
tenant.updatedAt = project.updatedAt;
await saveTenants(tenants);
return project;
};
export const removeTenantProject = async (tenantId: string, projectRef: string): Promise<void> => {
const tenants = await loadTenants();
const tenant = tenants.find((item) => item.id === tenantId);
if (!tenant) {
throw new Error('Tenant not found');
}
const nextProjects = tenant.appwriteProjects.filter(
(item) => item.id !== projectRef && item.projectId !== projectRef
);
if (nextProjects.length === tenant.appwriteProjects.length) {
throw new Error('Project not found');
}
tenant.appwriteProjects = nextProjects;
tenant.updatedAt = new Date().toISOString();
await saveTenants(tenants);
};

View file

@ -1,27 +0,0 @@
import { promises as fs } from 'fs';
import path from 'path';
import { env } from '../../lib/env.js';
import type { Tenant } from './tenants.types.js';
const dataFile = path.join(env.dataDir, 'tenants.json');
const ensureDataDir = async () => {
await fs.mkdir(env.dataDir, { recursive: true });
};
export const loadTenants = async (): Promise<Tenant[]> => {
try {
const content = await fs.readFile(dataFile, 'utf-8');
return JSON.parse(content) as Tenant[];
} catch (error) {
if (typeof error === 'object' && error !== null && 'code' in error && (error as { code?: string }).code === 'ENOENT') {
return [];
}
throw error;
}
};
export const saveTenants = async (tenants: Tenant[]): Promise<void> => {
await ensureDataDir();
await fs.writeFile(dataFile, JSON.stringify(tenants, null, 2));
};

View file

@ -1,35 +0,0 @@
import type { FinopsSummary } from '../finops/finops.service.js';
export type AppwriteProject = {
id: string;
name: string;
endpoint: string;
projectId: string;
apiKey: string;
createdAt: string;
updatedAt: string;
};
export type Tenant = {
id: string;
name: string;
slug: string;
status: 'active' | 'inactive';
createdAt: string;
updatedAt: string;
appwriteProjects: AppwriteProject[];
finops: FinopsSummary;
};
export type TenantInput = {
name: string;
slug?: string;
};
export type AppwriteProjectInput = {
name: string;
endpoint: string;
projectId?: string;
apiKey: string;
createProject?: boolean;
};

View file

@ -0,0 +1,48 @@
import { ProviderMetrics, ProviderProject, ProviderSecrets, SchemaDefinition } from '../../core/types.js';
import { logger } from '../../lib/logger.js';
export class AppwriteClient {
async createProject(name: string, secrets: ProviderSecrets): Promise<ProviderProject> {
logger.info('Appwrite create project requested', { name, endpoint: secrets.endpoint });
return {
externalId: `appwrite_${crypto.randomUUID()}`,
dashboardUrl: `${secrets.endpoint}/console/project`,
};
}
async deleteProject(externalId: string, secrets: ProviderSecrets): Promise<void> {
logger.info('Appwrite delete project requested', { externalId, endpoint: secrets.endpoint });
}
async applySchema(externalId: string, schema: SchemaDefinition, secrets: ProviderSecrets): Promise<void> {
logger.info('Appwrite apply schema requested', {
externalId,
version: schema.version,
endpoint: secrets.endpoint,
});
}
async collectMetrics(externalId: string, secrets: ProviderSecrets): Promise<ProviderMetrics> {
logger.info('Appwrite metrics requested', { externalId, endpoint: secrets.endpoint });
return {
users: 0,
storageMb: 0,
requests: 0,
functions: 0,
capturedAt: new Date().toISOString(),
};
}
async rotateSecrets(externalId: string, secrets: ProviderSecrets): Promise<ProviderSecrets> {
logger.info('Appwrite secrets rotation requested', { externalId, endpoint: secrets.endpoint });
return {
...secrets,
apiKey: `${secrets.apiKey}-rotated`,
};
}
async healthCheck(secrets: ProviderSecrets): Promise<boolean> {
logger.info('Appwrite health check requested', { endpoint: secrets.endpoint });
return true;
}
}

View file

@ -0,0 +1,10 @@
import { ProviderMetrics, ProviderSecrets } from '../../core/types.js';
import { AppwriteClient } from './appwrite.client.js';
const client = new AppwriteClient();
export const appwriteMetrics = {
async collect(externalId: string, secrets: ProviderSecrets): Promise<ProviderMetrics> {
return client.collectMetrics(externalId, secrets);
},
};

View file

@ -0,0 +1,31 @@
import { ProviderInterface } from '../../core/provider.interface.js';
import { ProviderMetrics, ProviderProject, ProviderSecrets, SchemaDefinition } from '../../core/types.js';
import { AppwriteClient } from './appwrite.client.js';
export class AppwriteProvider implements ProviderInterface {
private readonly client = new AppwriteClient();
async createProject(name: string, secrets: ProviderSecrets): Promise<ProviderProject> {
return this.client.createProject(name, secrets);
}
async deleteProject(externalId: string, secrets: ProviderSecrets): Promise<void> {
await this.client.deleteProject(externalId, secrets);
}
async applySchema(externalId: string, schema: SchemaDefinition, secrets: ProviderSecrets): Promise<void> {
await this.client.applySchema(externalId, schema, secrets);
}
async collectMetrics(externalId: string, secrets: ProviderSecrets): Promise<ProviderMetrics> {
return this.client.collectMetrics(externalId, secrets);
}
async rotateSecrets(externalId: string, secrets: ProviderSecrets): Promise<ProviderSecrets> {
return this.client.rotateSecrets(externalId, secrets);
}
async healthCheck(secrets: ProviderSecrets): Promise<boolean> {
return this.client.healthCheck(secrets);
}
}

View file

@ -0,0 +1,10 @@
import { SchemaDefinition, ProviderSecrets } from '../../core/types.js';
import { AppwriteClient } from './appwrite.client.js';
const client = new AppwriteClient();
export const appwriteSchema = {
async apply(externalId: string, schema: SchemaDefinition, secrets: ProviderSecrets): Promise<void> {
await client.applySchema(externalId, schema, secrets);
},
};

View file

@ -0,0 +1,48 @@
import { ProviderMetrics, ProviderProject, ProviderSecrets, SchemaDefinition } from '../../core/types.js';
import { logger } from '../../lib/logger.js';
export class SupabaseClient {
async createProject(name: string, secrets: ProviderSecrets): Promise<ProviderProject> {
logger.info('Supabase create project requested', { name, endpoint: secrets.endpoint });
return {
externalId: `supabase_${crypto.randomUUID()}`,
dashboardUrl: `${secrets.endpoint}/project`,
};
}
async deleteProject(externalId: string, secrets: ProviderSecrets): Promise<void> {
logger.info('Supabase delete project requested', { externalId, endpoint: secrets.endpoint });
}
async applySchema(externalId: string, schema: SchemaDefinition, secrets: ProviderSecrets): Promise<void> {
logger.info('Supabase apply schema requested', {
externalId,
version: schema.version,
endpoint: secrets.endpoint,
});
}
async collectMetrics(externalId: string, secrets: ProviderSecrets): Promise<ProviderMetrics> {
logger.info('Supabase metrics requested', { externalId, endpoint: secrets.endpoint });
return {
users: 0,
storageMb: 0,
requests: 0,
functions: 0,
capturedAt: new Date().toISOString(),
};
}
async rotateSecrets(externalId: string, secrets: ProviderSecrets): Promise<ProviderSecrets> {
logger.info('Supabase secrets rotation requested', { externalId, endpoint: secrets.endpoint });
return {
...secrets,
apiKey: `${secrets.apiKey}-rotated`,
};
}
async healthCheck(secrets: ProviderSecrets): Promise<boolean> {
logger.info('Supabase health check requested', { endpoint: secrets.endpoint });
return true;
}
}

View file

@ -0,0 +1,10 @@
import { ProviderMetrics, ProviderSecrets } from '../../core/types.js';
import { SupabaseClient } from './supabase.client.js';
const client = new SupabaseClient();
export const supabaseMetrics = {
async collect(externalId: string, secrets: ProviderSecrets): Promise<ProviderMetrics> {
return client.collectMetrics(externalId, secrets);
},
};

View file

@ -0,0 +1,31 @@
import { ProviderInterface } from '../../core/provider.interface.js';
import { ProviderMetrics, ProviderProject, ProviderSecrets, SchemaDefinition } from '../../core/types.js';
import { SupabaseClient } from './supabase.client.js';
export class SupabaseProvider implements ProviderInterface {
private readonly client = new SupabaseClient();
async createProject(name: string, secrets: ProviderSecrets): Promise<ProviderProject> {
return this.client.createProject(name, secrets);
}
async deleteProject(externalId: string, secrets: ProviderSecrets): Promise<void> {
await this.client.deleteProject(externalId, secrets);
}
async applySchema(externalId: string, schema: SchemaDefinition, secrets: ProviderSecrets): Promise<void> {
await this.client.applySchema(externalId, schema, secrets);
}
async collectMetrics(externalId: string, secrets: ProviderSecrets): Promise<ProviderMetrics> {
return this.client.collectMetrics(externalId, secrets);
}
async rotateSecrets(externalId: string, secrets: ProviderSecrets): Promise<ProviderSecrets> {
return this.client.rotateSecrets(externalId, secrets);
}
async healthCheck(secrets: ProviderSecrets): Promise<boolean> {
return this.client.healthCheck(secrets);
}
}

View file

@ -0,0 +1,10 @@
import { ProviderSecrets, SchemaDefinition } from '../../core/types.js';
import { SupabaseClient } from './supabase.client.js';
const client = new SupabaseClient();
export const supabaseSchema = {
async apply(externalId: string, schema: SchemaDefinition, secrets: ProviderSecrets): Promise<void> {
await client.applySchema(externalId, schema, secrets);
},
};

View file

@ -1,28 +0,0 @@
#!/usr/bin/env node
import { applySchema } from '../lib/appwrite.js';
import { env } from '../lib/env.js';
import { logger } from '../lib/logger.js';
const endpoint = process.env.APPWRITE_SETUP_ENDPOINT || env.appwriteAdmin.endpoint || env.defaults.appwriteEndpoint;
const projectId = process.env.APPWRITE_SETUP_PROJECT_ID || '';
const apiKey = process.env.APPWRITE_SETUP_API_KEY || env.appwriteAdmin.apiKey;
if (!endpoint || !projectId || !apiKey) {
logger.error('Missing Appwrite setup credentials', {
endpoint: Boolean(endpoint),
projectId: Boolean(projectId),
apiKey: Boolean(apiKey),
});
process.exit(1);
}
const run = async () => {
const result = await applySchema({ endpoint, projectId, apiKey }, { logPrefix: 'setup' });
logger.info('Setup completed', result);
};
run().catch((error: Error) => {
logger.error('Setup failed', { message: error.message });
process.exit(1);
});