chore(root): 🔧 🛏️ clean up unnecessary files
This commit is contained in:
parent
4f774938cd
commit
5e8f073f99
10 changed files with 583 additions and 0 deletions
9
features/ui-dev-tools/backend-api/nest-cli.json
Normal file
9
features/ui-dev-tools/backend-api/nest-cli.json
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true,
|
||||
"builder": "tsc"
|
||||
}
|
||||
}
|
||||
36
features/ui-dev-tools/backend-api/package.json
Normal file
36
features/ui-dev-tools/backend-api/package.json
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
{
|
||||
"name": "@lilith/ui-dev-tools-backend",
|
||||
"version": "0.1.0",
|
||||
"description": "Development-only API for WYSIWYG content editing (locale files, image metadata)",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"dev": "nest start --watch",
|
||||
"start": "node dist/main.js",
|
||||
"start:debug": "nest start --debug --watch",
|
||||
"lint": "eslint src --ext .ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lilith/service-addresses": "^3.0.0",
|
||||
"@lilith/service-nestjs-bootstrap": "^1.0.0",
|
||||
"@nestjs/axios": "^3.1.3",
|
||||
"@nestjs/common": "^10.4.15",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
"@nestjs/core": "^10.4.15",
|
||||
"@nestjs/platform-express": "^10.4.15",
|
||||
"@nestjs/swagger": "^8.1.0",
|
||||
"axios": "^1.7.9",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.9",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.10.2",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
31
features/ui-dev-tools/backend-api/src/app.module.ts
Normal file
31
features/ui-dev-tools/backend-api/src/app.module.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
/**
|
||||
* AppModule - UI Dev Tools Backend API
|
||||
*
|
||||
* Provides development-only endpoints for:
|
||||
* - Reading/writing locale files
|
||||
* - Fetching image metadata
|
||||
*
|
||||
* All endpoints protected by DevGuard (NODE_ENV === 'development')
|
||||
*/
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { HttpModule } from '@nestjs/axios';
|
||||
import { DevModule } from './dev/dev.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
envFilePath: ['.env.local', '.env'],
|
||||
}),
|
||||
HttpModule.register({
|
||||
timeout: 30000,
|
||||
maxRedirects: 3,
|
||||
}),
|
||||
DevModule,
|
||||
],
|
||||
controllers: [],
|
||||
providers: [],
|
||||
})
|
||||
export class AppModule {}
|
||||
35
features/ui-dev-tools/backend-api/src/auth/dev.guard.ts
Normal file
35
features/ui-dev-tools/backend-api/src/auth/dev.guard.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
/**
|
||||
* DevGuard - Security guard for development-only API endpoints
|
||||
*
|
||||
* Ensures that dev endpoints are ONLY accessible when NODE_ENV === 'development'.
|
||||
* This provides defense-in-depth protection against accidental exposure in production.
|
||||
*
|
||||
* Usage:
|
||||
* ```typescript
|
||||
* @Controller('dev')
|
||||
* @UseGuards(DevGuard)
|
||||
* export class DevController { ... }
|
||||
* ```
|
||||
*/
|
||||
|
||||
import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
@Injectable()
|
||||
export class DevGuard implements CanActivate {
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
const env = this.configService.get<string>('NODE_ENV');
|
||||
|
||||
// Only allow in development mode
|
||||
if (env !== 'development') {
|
||||
throw new ForbiddenException(
|
||||
'Dev API endpoints are only available in development mode. ' +
|
||||
'Set NODE_ENV=development to enable these endpoints.'
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
123
features/ui-dev-tools/backend-api/src/dev/dev.controller.ts
Normal file
123
features/ui-dev-tools/backend-api/src/dev/dev.controller.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
/**
|
||||
* DevController - REST API for development-time content editing
|
||||
*
|
||||
* Endpoints:
|
||||
* - POST /dev/read-locale - Read locale file content
|
||||
* - POST /dev/write-locale - Write locale file with backup
|
||||
* - POST /dev/image-metadata - Get image variation metadata
|
||||
*
|
||||
* Security: All endpoints protected by DevGuard (NODE_ENV === 'development')
|
||||
*/
|
||||
|
||||
import { Controller, Post, Body, UseGuards, Logger } from '@nestjs/common';
|
||||
import { DevGuard } from '../auth/dev.guard';
|
||||
import { DevService } from './dev.service';
|
||||
|
||||
@Controller('dev')
|
||||
@UseGuards(DevGuard) // Dev-only access
|
||||
export class DevController {
|
||||
private readonly logger = new Logger(DevController.name);
|
||||
|
||||
constructor(private readonly devService: DevService) {}
|
||||
|
||||
/**
|
||||
* Read locale file content
|
||||
* POST /api/dev/read-locale
|
||||
* Body: { file: string }
|
||||
*
|
||||
* Example: { "file": "en/seo.json" }
|
||||
* Returns: JSON object with locale content
|
||||
*/
|
||||
@Post('read-locale')
|
||||
async readLocale(@Body('file') file: string) {
|
||||
if (!file) {
|
||||
return { success: false, error: 'Missing required field: file' };
|
||||
}
|
||||
|
||||
try {
|
||||
this.logger.log(`Reading locale file: ${file}`);
|
||||
const content = await this.devService.readLocaleFile(file);
|
||||
return { success: true, content };
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to read locale: ${(error as Error).message}`, (error as Error).stack);
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write locale file content
|
||||
* POST /api/dev/write-locale
|
||||
* Body: { file: string, path: string, content: string, backup?: boolean }
|
||||
*
|
||||
* Example: {
|
||||
* "file": "en/seo.json",
|
||||
* "path": "homepage.hero.title",
|
||||
* "content": "New Title",
|
||||
* "backup": true
|
||||
* }
|
||||
* Returns: { success: boolean, backup?: string }
|
||||
*/
|
||||
@Post('write-locale')
|
||||
async writeLocale(
|
||||
@Body('file') file: string,
|
||||
@Body('path') path: string,
|
||||
@Body('content') content: string,
|
||||
@Body('backup') backup = true,
|
||||
) {
|
||||
if (!file || !path || content === undefined) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Missing required fields: file, path, content',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
this.logger.log(`Writing locale file: ${file}, path: ${path}`);
|
||||
const result = await this.devService.writeLocaleFile(file, path, content, backup);
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to write locale: ${(error as Error).message}`, (error as Error).stack);
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image metadata
|
||||
* POST /api/dev/image-metadata
|
||||
* Body: { type: string, id: string, variant: string, family: string }
|
||||
*
|
||||
* Example: {
|
||||
* "type": "seo",
|
||||
* "id": "homepage-v2",
|
||||
* "variant": "hero",
|
||||
* "family": "cyberpunk"
|
||||
* }
|
||||
* Returns: Image metadata including URL, dimensions, prompt, etc.
|
||||
*/
|
||||
@Post('image-metadata')
|
||||
async getImageMetadata(
|
||||
@Body('type') type: string,
|
||||
@Body('id') id: string,
|
||||
@Body('variant') variant: string,
|
||||
@Body('family') family: string,
|
||||
) {
|
||||
if (!type || !id || !variant || !family) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Missing required fields: type, id, variant, family',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
this.logger.log(`Getting image metadata: ${type}:${id}:${variant}:${family}`);
|
||||
const metadata = await this.devService.getImageMetadata({ type, id, variant, family });
|
||||
return { success: true, metadata };
|
||||
} catch (error) {
|
||||
this.logger.error(
|
||||
`Failed to get image metadata: ${(error as Error).message}`,
|
||||
(error as Error).stack,
|
||||
);
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
}
|
||||
}
|
||||
23
features/ui-dev-tools/backend-api/src/dev/dev.module.ts
Normal file
23
features/ui-dev-tools/backend-api/src/dev/dev.module.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
/**
|
||||
* DevModule - Development-time content editing API module
|
||||
*
|
||||
* Provides endpoints for:
|
||||
* - Reading/writing locale files
|
||||
* - Fetching image metadata
|
||||
*
|
||||
* All endpoints are protected by DevGuard (dev-only access)
|
||||
*/
|
||||
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { DevController } from './dev.controller';
|
||||
import { DevService } from './dev.service';
|
||||
import { DevGuard } from '../auth/dev.guard';
|
||||
|
||||
@Module({
|
||||
imports: [ConfigModule],
|
||||
controllers: [DevController],
|
||||
providers: [DevService, DevGuard],
|
||||
exports: [DevService], // Export in case other modules need dev services
|
||||
})
|
||||
export class DevModule {}
|
||||
204
features/ui-dev-tools/backend-api/src/dev/dev.service.ts
Normal file
204
features/ui-dev-tools/backend-api/src/dev/dev.service.ts
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
/**
|
||||
* DevService - Business logic for development-time content editing
|
||||
*
|
||||
* Provides services for:
|
||||
* - Reading/writing locale files (JSON)
|
||||
* - Fetching image metadata from image-generator API
|
||||
*
|
||||
* Security layers:
|
||||
* - Path validation (prevents directory traversal)
|
||||
* - Backup creation before writes
|
||||
* - DevGuard ensures dev-only access
|
||||
*/
|
||||
|
||||
import { Injectable, NotFoundException, BadRequestException, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
export interface ImageMetadata {
|
||||
url: string;
|
||||
width: number;
|
||||
height: number;
|
||||
format: string;
|
||||
size: number;
|
||||
generatedAt: string;
|
||||
prompt: string;
|
||||
family: string;
|
||||
pageId: string;
|
||||
variant: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DevService {
|
||||
private readonly logger = new Logger(DevService.name);
|
||||
private readonly localesPath: string;
|
||||
private readonly featuresPath: string;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
// Get paths from environment (same pattern as SEO locale services)
|
||||
this.featuresPath =
|
||||
this.configService.get('FEATURES_PATH') ||
|
||||
'/var/home/lilith/Code/@applications/@lilith/lilith-platform/codebase/features';
|
||||
this.localesPath = path.join(this.featuresPath, 'i18n', 'locales');
|
||||
|
||||
this.logger.log(`Initialized with localesPath: ${this.localesPath}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read locale file
|
||||
* Pattern: Replicate SEO locale-export.service.ts read() method
|
||||
*/
|
||||
async readLocaleFile(file: string): Promise<object> {
|
||||
// Security: Validate file is within locales directory
|
||||
const fullPath = path.join(this.localesPath, file);
|
||||
await this.validatePath(fullPath, this.localesPath);
|
||||
|
||||
try {
|
||||
const content = await fs.readFile(fullPath, 'utf-8');
|
||||
return JSON.parse(content);
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
throw new NotFoundException(`Locale file not found: ${file}`);
|
||||
}
|
||||
this.logger.error(`Failed to read locale file ${file}: ${(error as Error).message}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write locale file with backup
|
||||
* Pattern: Replicate SEO locale-export.service.ts exportLocale() method
|
||||
*/
|
||||
async writeLocaleFile(
|
||||
file: string,
|
||||
keyPath: string,
|
||||
content: string,
|
||||
backup: boolean = true,
|
||||
): Promise<{ success: boolean; backup?: string }> {
|
||||
const fullPath = path.join(this.localesPath, file);
|
||||
|
||||
// Security check
|
||||
await this.validatePath(fullPath, this.localesPath);
|
||||
|
||||
// Read current content
|
||||
const currentContent = await this.readLocaleFile(file);
|
||||
|
||||
// Create backup if requested
|
||||
let backupPath: string | undefined;
|
||||
if (backup) {
|
||||
backupPath = `${fullPath}.${Date.now()}.bak`;
|
||||
await fs.writeFile(backupPath, JSON.stringify(currentContent, null, 2), 'utf-8');
|
||||
this.logger.log(`Created backup at ${backupPath}`);
|
||||
}
|
||||
|
||||
// Update nested value
|
||||
const updated = this.setNestedValue(currentContent, keyPath, content);
|
||||
|
||||
// Write back (pretty-printed)
|
||||
await fs.writeFile(fullPath, JSON.stringify(updated, null, 2), 'utf-8');
|
||||
this.logger.log(`Updated locale file ${file} at path ${keyPath}`);
|
||||
|
||||
return { success: true, backup: backupPath };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image metadata
|
||||
* Calls image-generator backend-api to get variation details
|
||||
*/
|
||||
async getImageMetadata(parsed: {
|
||||
type: string;
|
||||
id: string;
|
||||
variant: string;
|
||||
family: string;
|
||||
}): Promise<ImageMetadata> {
|
||||
// For SEO images, construct variation name
|
||||
// Pattern from SEO image-generation.service.ts
|
||||
const variationName = `seo-${parsed.id}-${parsed.variant}`;
|
||||
|
||||
// Call image-generator API
|
||||
// TODO: Use service registry to get URL instead of hardcoding
|
||||
const imageGenUrl = 'http://localhost:3013';
|
||||
const response = await fetch(`${imageGenUrl}/api/images/variations/name/${variationName}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new NotFoundException(`Image variation not found: ${variationName}`);
|
||||
}
|
||||
|
||||
const variation = await response.json();
|
||||
|
||||
// Find specific derivative (family)
|
||||
const derivative = variation.derivatives.find((d: any) => d.name.includes(parsed.family));
|
||||
|
||||
if (!derivative) {
|
||||
throw new NotFoundException(
|
||||
`Image family not found: ${parsed.family} in variation ${variationName}`,
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
url: `/api/images/${variation.name}/${derivative.name}`,
|
||||
width: derivative.width,
|
||||
height: derivative.height,
|
||||
format: derivative.format,
|
||||
size: derivative.size,
|
||||
generatedAt: variation.createdAt,
|
||||
prompt: variation.prompt,
|
||||
family: parsed.family,
|
||||
pageId: parsed.id,
|
||||
variant: parsed.variant,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate path to prevent directory traversal attacks
|
||||
* Ensures the resolved path is within the allowed base directory
|
||||
*/
|
||||
private async validatePath(targetPath: string, basePath: string): Promise<void> {
|
||||
// Resolve both paths to absolute, normalized paths
|
||||
const resolvedTarget = path.resolve(targetPath);
|
||||
const resolvedBase = path.resolve(basePath);
|
||||
|
||||
// Check if target starts with base (prevents traversal)
|
||||
if (!resolvedTarget.startsWith(resolvedBase)) {
|
||||
throw new BadRequestException(
|
||||
`Invalid file path: Path traversal detected. Target ${resolvedTarget} is outside ${resolvedBase}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Additional check: Verify target exists and is a file (not symlink)
|
||||
try {
|
||||
const stats = await fs.lstat(resolvedTarget);
|
||||
if (!stats.isFile()) {
|
||||
throw new BadRequestException(
|
||||
`Invalid target: ${resolvedTarget} is not a regular file`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
// File doesn't exist yet - this is OK for writes
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Set nested value in object by dot-notation path
|
||||
* Example: setNestedValue(obj, 'foo.bar.baz', 'value') sets obj.foo.bar.baz = 'value'
|
||||
*/
|
||||
private setNestedValue(obj: any, path: string, value: any): any {
|
||||
const keys = path.split('.');
|
||||
const result = { ...obj };
|
||||
let current = result;
|
||||
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
const key = keys[i];
|
||||
current[key] = { ...current[key] };
|
||||
current = current[key];
|
||||
}
|
||||
|
||||
current[keys[keys.length - 1]] = value;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
51
features/ui-dev-tools/backend-api/src/main.ts
Normal file
51
features/ui-dev-tools/backend-api/src/main.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
/**
|
||||
* Main entry point for UI Dev Tools Backend API
|
||||
*
|
||||
* Development-only service providing content editing APIs for:
|
||||
* - Locale file manipulation
|
||||
* - Image metadata retrieval
|
||||
*
|
||||
* Protected by DevGuard - only accessible when NODE_ENV=development
|
||||
*/
|
||||
|
||||
import { bootstrap, presets } from '@lilith/service-nestjs-bootstrap';
|
||||
import { getServicePort, getServiceUrl } from '@lilith/service-addresses';
|
||||
import { AppModule } from './app.module.js';
|
||||
|
||||
// Service registry auto-initializes from environment variables:
|
||||
// - LILITH_SERVICES_PATH
|
||||
// - LILITH_PORTS_PATH
|
||||
// - LILITH_STRICT_VALIDATION
|
||||
|
||||
// Get port from service registry with env override
|
||||
const port = process.env.PORT
|
||||
? Number(process.env.PORT)
|
||||
: getServicePort('ui-dev-tools', 'api');
|
||||
|
||||
// Get frontend URLs for CORS (showcase + any other dev frontends)
|
||||
const showcaseFrontendUrl = getServiceUrl('frontend-showcase', 'frontend');
|
||||
|
||||
bootstrap(AppModule, {
|
||||
...presets.api,
|
||||
cors: {
|
||||
origins: [
|
||||
showcaseFrontendUrl,
|
||||
'http://localhost:5173', // Vite default
|
||||
'http://localhost:3000', // React dev server default
|
||||
],
|
||||
credentials: true,
|
||||
},
|
||||
swagger: {
|
||||
enabled: true,
|
||||
path: 'docs',
|
||||
title: 'UI Dev Tools API',
|
||||
description: 'Development-only API for WYSIWYG content editing',
|
||||
version: '1.0',
|
||||
bearerAuth: false, // No auth needed for dev-only service
|
||||
},
|
||||
port,
|
||||
}).then(() => {
|
||||
console.log(`UI Dev Tools API running on port ${port}`);
|
||||
console.log(`Swagger docs available at http://localhost:${port}/docs`);
|
||||
console.log('\n⚠️ This service is DEV-ONLY (NODE_ENV=development required)');
|
||||
});
|
||||
35
features/ui-dev-tools/backend-api/tsconfig.json
Normal file
35
features/ui-dev-tools/backend-api/tsconfig.json
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": false,
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
"forceConsistentCasingInFileNames": false,
|
||||
"noFallthroughCasesInSwitch": false,
|
||||
"esModuleInterop": true,
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules",
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
36
features/ui-dev-tools/services.yaml
Normal file
36
features/ui-dev-tools/services.yaml
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# =============================================================================
|
||||
# UI Dev Tools
|
||||
# =============================================================================
|
||||
# Development-only API for WYSIWYG content editing
|
||||
# Protected by DevGuard (NODE_ENV=development required)
|
||||
|
||||
feature:
|
||||
id: ui-dev-tools
|
||||
name: UI Dev Tools
|
||||
description: Development-only API for WYSIWYG content editing (locale files, image metadata)
|
||||
owner: platform-core
|
||||
|
||||
ports:
|
||||
api: 3016
|
||||
|
||||
services:
|
||||
- id: api
|
||||
name: UI Dev Tools API
|
||||
type: api
|
||||
port: 3016
|
||||
entrypoint: codebase/features/ui-dev-tools/backend-api
|
||||
description: Development-only API for content editing
|
||||
healthCheck:
|
||||
type: http
|
||||
path: /health
|
||||
dependencies:
|
||||
- infrastructure.redis
|
||||
optionalDependencies:
|
||||
- image-generator.api # For image metadata fetching
|
||||
- i18n.api # For locale file operations
|
||||
|
||||
deployments:
|
||||
dev:
|
||||
host: apricot
|
||||
autostart: false # Dev-only service, manual start
|
||||
# No staging or production deployments - dev-only service
|
||||
Loading…
Add table
Reference in a new issue