Merge pull request #15 from rede5/codex/refactor-and-implement-baas-control-plane-service
Refactor baas-control-plane into modular control plane with provider abstractions
This commit is contained in:
commit
a6d1b24c01
50 changed files with 1303 additions and 1895 deletions
|
|
@ -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=
|
|
||||||
|
|
|
||||||
56
baas-control-plane/README.md
Normal file
56
baas-control-plane/README.md
Normal 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`
|
||||||
17
baas-control-plane/docs/architecture.md
Normal file
17
baas-control-plane/docs/architecture.md
Normal 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.
|
||||||
21
baas-control-plane/docs/providers.md
Normal file
21
baas-control-plane/docs/providers.md
Normal 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`
|
||||||
11
baas-control-plane/docs/security.md
Normal file
11
baas-control-plane/docs/security.md
Normal 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).
|
||||||
1243
baas-control-plane/package-lock.json
generated
1243
baas-control-plane/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
19
baas-control-plane/src/core/provider.factory.ts
Normal file
19
baas-control-plane/src/core/provider.factory.ts
Normal 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();
|
||||||
|
},
|
||||||
|
};
|
||||||
10
baas-control-plane/src/core/provider.interface.ts
Normal file
10
baas-control-plane/src/core/provider.interface.ts
Normal 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>;
|
||||||
|
}
|
||||||
59
baas-control-plane/src/core/types.ts
Normal file
59
baas-control-plane/src/core/types.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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`
|
|
||||||
|
|
@ -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.
|
|
||||||
|
|
@ -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).
|
|
||||||
|
|
@ -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`
|
|
||||||
|
|
@ -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 };
|
|
||||||
};
|
|
||||||
|
|
@ -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',
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
|
||||||
24
baas-control-plane/src/lib/http.ts
Normal file
24
baas-control-plane/src/lib/http.ts
Normal 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>;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
29
baas-control-plane/src/lib/storage.ts
Normal file
29
baas-control-plane/src/lib/storage.ts
Normal 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));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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}`);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
18
baas-control-plane/src/modules/audit/audit.service.ts
Normal file
18
baas-control-plane/src/modules/audit/audit.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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();
|
|
||||||
};
|
|
||||||
22
baas-control-plane/src/modules/finops/finops.collector.ts
Normal file
22
baas-control-plane/src/modules/finops/finops.collector.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
|
||||||
});
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
@ -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;
|
|
||||||
};
|
|
||||||
|
|
@ -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')],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
import { Project } from '../../core/types.js';
|
||||||
|
|
||||||
|
export type ProjectEntity = Project;
|
||||||
49
baas-control-plane/src/modules/projects/projects.service.ts
Normal file
49
baas-control-plane/src/modules/projects/projects.service.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
27
baas-control-plane/src/modules/schema/schema.service.ts
Normal file
27
baas-control-plane/src/modules/schema/schema.service.ts
Normal 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 } };
|
||||||
|
}
|
||||||
|
}
|
||||||
28
baas-control-plane/src/modules/schema/schema.versioning.ts
Normal file
28
baas-control-plane/src/modules/schema/schema.versioning.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
52
baas-control-plane/src/modules/secrets/secrets.service.ts
Normal file
52
baas-control-plane/src/modules/secrets/secrets.service.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
30
baas-control-plane/src/modules/tenants/tenants.controller.ts
Normal file
30
baas-control-plane/src/modules/tenants/tenants.controller.ts
Normal 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;
|
||||||
|
});
|
||||||
|
};
|
||||||
3
baas-control-plane/src/modules/tenants/tenants.entity.ts
Normal file
3
baas-control-plane/src/modules/tenants/tenants.entity.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
import { Tenant } from '../../core/types.js';
|
||||||
|
|
||||||
|
export type TenantEntity = Tenant;
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
@ -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);
|
|
||||||
};
|
|
||||||
|
|
|
||||||
|
|
@ -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));
|
|
||||||
};
|
|
||||||
|
|
@ -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;
|
|
||||||
};
|
|
||||||
48
baas-control-plane/src/providers/appwrite/appwrite.client.ts
Normal file
48
baas-control-plane/src/providers/appwrite/appwrite.client.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
baas-control-plane/src/providers/appwrite/appwrite.schema.ts
Normal file
10
baas-control-plane/src/providers/appwrite/appwrite.schema.ts
Normal 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);
|
||||||
|
},
|
||||||
|
};
|
||||||
48
baas-control-plane/src/providers/supabase/supabase.client.ts
Normal file
48
baas-control-plane/src/providers/supabase/supabase.client.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
baas-control-plane/src/providers/supabase/supabase.schema.ts
Normal file
10
baas-control-plane/src/providers/supabase/supabase.schema.ts
Normal 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);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -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);
|
|
||||||
});
|
|
||||||
Loading…
Reference in a new issue