refactor: remove project-specific code and fix broken imports (Phase 7)
CRITICAL FIX: src/routes/index.js was importing 10 non-existent route files - Repository would CRASH ON STARTUP REMOVED (8 files): - src/config/currencies.config.js - Koha donation system (10 currencies, exchange rates) - src/routes/hooks-metrics.routes.js - Required deleted auth.middleware - src/routes/sync-health.routes.js - Required deleted auth.middleware - src/utils/security-logger.js - Hardcoded /var/log/tractatus paths, OUR inst_046 - scripts/seed-admin.js - Required deleted User.model - scripts/validate-deployment.js - OUR deployment validation (inst_025) - systemd/tractatus-dev.service - OUR server at /var/www/tractatus - systemd/tractatus-prod.service - OUR production server config REWRITTEN (2 files): src/routes/index.js - Removed imports: auth, documents, blog, newsletter, media, cases, admin, koha, demo, test - Removed imports: hooks-metrics, sync-health (just deleted) - Keep only: rules, projects, audit, governance (framework routes) - Removed website endpoint documentation - Updated to framework v3.5.0 src/config/app.config.js - Removed: JWT config (auth system deleted) - Removed: admin.email = john.stroh.nz@pm.me (hardcoded project-specific) - Removed: features.aiCuration/mediaTriage/caseSubmissions (website features) - Keep only: server, mongodb, logging, security (rate limiting), CORS - Now generic template for implementers RESULT: Repository can now start without errors, all imports resolve 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
6496e0d811
commit
5ca2777815
10 changed files with 46 additions and 1092 deletions
|
|
@ -1,113 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* Seed Admin User Script
|
||||
* Creates the initial admin user for the Tractatus platform
|
||||
*
|
||||
* Usage: npm run seed:admin
|
||||
*/
|
||||
|
||||
require('dotenv').config();
|
||||
|
||||
const readline = require('readline');
|
||||
const { connect, close } = require('../src/utils/db.util');
|
||||
const User = require('../src/models/User.model');
|
||||
const logger = require('../src/utils/logger.util');
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
});
|
||||
|
||||
function question(prompt) {
|
||||
return new Promise((resolve) => {
|
||||
rl.question(prompt, resolve);
|
||||
});
|
||||
}
|
||||
|
||||
async function seedAdmin() {
|
||||
try {
|
||||
console.log('\n=== Tractatus Admin User Setup ===\n');
|
||||
|
||||
// Connect to database
|
||||
await connect();
|
||||
|
||||
// Check if admin user already exists
|
||||
const existingAdmin = await User.findByEmail(process.env.ADMIN_EMAIL || 'admin@tractatus.local');
|
||||
|
||||
if (existingAdmin) {
|
||||
console.log('⚠️ Admin user already exists.');
|
||||
const overwrite = await question('Do you want to delete and recreate? (yes/no): ');
|
||||
|
||||
if (overwrite.toLowerCase() !== 'yes') {
|
||||
console.log('Cancelled. No changes made.');
|
||||
await cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
await User.deleteOne({ _id: existingAdmin._id });
|
||||
console.log('✅ Existing admin user deleted.');
|
||||
}
|
||||
|
||||
// Get admin details
|
||||
console.log('\nEnter admin user details:');
|
||||
const name = await question('Name (default: Admin User): ') || 'Admin User';
|
||||
const email = await question(`Email (default: ${process.env.ADMIN_EMAIL || 'admin@tractatus.local'}): `)
|
||||
|| process.env.ADMIN_EMAIL
|
||||
|| 'admin@tractatus.local';
|
||||
|
||||
// Password input (hidden)
|
||||
console.log('\n⚠️ Password will be visible. Use a development password only.');
|
||||
const password = await question('Password (min 8 chars): ');
|
||||
|
||||
if (!password || password.length < 8) {
|
||||
console.error('❌ Password must be at least 8 characters.');
|
||||
await cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
// Create admin user
|
||||
const admin = await User.create({
|
||||
name,
|
||||
email,
|
||||
password, // Will be hashed by the model
|
||||
role: 'admin',
|
||||
active: true
|
||||
});
|
||||
|
||||
console.log('\n✅ Admin user created successfully!');
|
||||
console.log('\nCredentials:');
|
||||
console.log(` Email: ${admin.email}`);
|
||||
console.log(` Role: ${admin.role}`);
|
||||
console.log(` ID: ${admin._id}`);
|
||||
console.log('\nYou can now login at: POST /api/auth/login');
|
||||
console.log('');
|
||||
|
||||
logger.info(`Admin user created: ${admin.email}`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('\n❌ Error creating admin user:', error.message);
|
||||
logger.error('Admin seed error:', error);
|
||||
process.exit(1);
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanup() {
|
||||
rl.close();
|
||||
await close();
|
||||
}
|
||||
|
||||
// Handle Ctrl+C
|
||||
process.on('SIGINT', async () => {
|
||||
console.log('\n\n👋 Cancelled by user');
|
||||
await cleanup();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Run if called directly
|
||||
if (require.main === module) {
|
||||
seedAdmin();
|
||||
}
|
||||
|
||||
module.exports = seedAdmin;
|
||||
|
|
@ -1,236 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Pre-Deployment Validation
|
||||
*
|
||||
* Validates rsync/scp/deployment commands against inst_025 rules:
|
||||
* - Checks if source files have different subdirectories
|
||||
* - Ensures separate commands for different directory levels
|
||||
* - Prevents directory structure flattening
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/validate-deployment.js --command "rsync ..."
|
||||
* node scripts/validate-deployment.js --files "file1 file2" --target "remote:path"
|
||||
*
|
||||
* Exit codes:
|
||||
* 0 = Valid deployment
|
||||
* 1 = Invalid (violates inst_025)
|
||||
* 2 = Error
|
||||
*
|
||||
* Copyright 2025 Tractatus Project
|
||||
* Licensed under Apache License 2.0
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
/**
|
||||
* Parse rsync command to extract source files and target
|
||||
*/
|
||||
function parseRsyncCommand(command) {
|
||||
// Match rsync with options and files
|
||||
const rsyncPattern = /rsync\s+([^"'\s]+(?:\s+[^"'\s]+)*)\s+((?:[^\s]+\s+)*)([\w@.-]+:[^\s]+|[^\s]+)$/;
|
||||
const match = command.match(rsyncPattern);
|
||||
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Extract flags and files
|
||||
const parts = command.split(/\s+/).filter(p => p.length > 0);
|
||||
const rsyncIndex = parts.findIndex(p => p === 'rsync' || p.endsWith('/rsync'));
|
||||
|
||||
if (rsyncIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const filesAndTarget = parts.slice(rsyncIndex + 1);
|
||||
|
||||
// Last item is target
|
||||
const target = filesAndTarget[filesAndTarget.length - 1];
|
||||
|
||||
// Everything before target that's not a flag is a file
|
||||
const files = [];
|
||||
for (let i = 0; i < filesAndTarget.length - 1; i++) {
|
||||
const item = filesAndTarget[i];
|
||||
if (!item.startsWith('-')) {
|
||||
files.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
files,
|
||||
target,
|
||||
command
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if files have different subdirectory paths
|
||||
*/
|
||||
function checkDirectoryMismatch(files) {
|
||||
if (files.length <= 1) {
|
||||
return { hasMismatch: false, directories: [] };
|
||||
}
|
||||
|
||||
const directories = files.map(f => {
|
||||
const dir = path.dirname(f);
|
||||
return dir === '.' ? '' : dir;
|
||||
});
|
||||
|
||||
const uniqueDirs = [...new Set(directories)];
|
||||
|
||||
return {
|
||||
hasMismatch: uniqueDirs.length > 1,
|
||||
directories: uniqueDirs,
|
||||
filesByDir: Object.fromEntries(
|
||||
uniqueDirs.map(dir => [
|
||||
dir,
|
||||
files.filter(f => path.dirname(f) === (dir || '.'))
|
||||
])
|
||||
)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate deployment command
|
||||
*/
|
||||
function validateDeployment(command) {
|
||||
const parsed = parseRsyncCommand(command);
|
||||
|
||||
if (!parsed) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'Could not parse rsync command',
|
||||
suggestion: null
|
||||
};
|
||||
}
|
||||
|
||||
const { files, target } = parsed;
|
||||
|
||||
if (files.length === 0) {
|
||||
return {
|
||||
valid: false,
|
||||
error: 'No source files specified',
|
||||
suggestion: null
|
||||
};
|
||||
}
|
||||
|
||||
// Check for directory mismatch
|
||||
const dirCheck = checkDirectoryMismatch(files);
|
||||
|
||||
if (!dirCheck.hasMismatch) {
|
||||
return {
|
||||
valid: true,
|
||||
message: 'Deployment command is valid - all files in same directory',
|
||||
files,
|
||||
target
|
||||
};
|
||||
}
|
||||
|
||||
// Violation detected
|
||||
return {
|
||||
valid: false,
|
||||
error: `inst_025 violation: Files from different subdirectories in single rsync command`,
|
||||
details: {
|
||||
file_count: files.length,
|
||||
unique_directories: dirCheck.directories.length,
|
||||
directories: dirCheck.directories,
|
||||
filesByDir: dirCheck.filesByDir
|
||||
},
|
||||
suggestion: generateSeparateCommands(dirCheck.filesByDir, target, command)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate separate rsync commands for each directory
|
||||
*/
|
||||
function generateSeparateCommands(filesByDir, target, originalCommand) {
|
||||
const commands = [];
|
||||
|
||||
// Extract rsync flags from original command
|
||||
const flagMatch = originalCommand.match(/rsync\s+([^/\s][^\s]*)/);
|
||||
const flags = flagMatch ? flagMatch[1] : '-avz --progress';
|
||||
|
||||
Object.entries(filesByDir).forEach(([dir, files]) => {
|
||||
const targetWithDir = dir ? `${target}/${dir}/` : target;
|
||||
|
||||
files.forEach(file => {
|
||||
const cmd = `rsync ${flags} ${file} ${targetWithDir}`;
|
||||
commands.push(cmd);
|
||||
});
|
||||
});
|
||||
|
||||
return commands;
|
||||
}
|
||||
|
||||
/**
|
||||
* Display validation results
|
||||
*/
|
||||
function displayResults(result) {
|
||||
if (result.valid) {
|
||||
console.log('\x1b[32m✅ Deployment command is VALID\x1b[0m');
|
||||
console.log(` Files: ${result.files.length}`);
|
||||
console.log(` Target: ${result.target}`);
|
||||
return 0;
|
||||
}
|
||||
|
||||
console.log('\x1b[31m❌ Deployment command VIOLATES inst_025\x1b[0m');
|
||||
console.log(`\n Error: ${result.error}\n`);
|
||||
|
||||
if (result.details) {
|
||||
console.log(' Details:');
|
||||
console.log(` File count: ${result.details.file_count}`);
|
||||
console.log(` Unique directories: ${result.details.unique_directories}`);
|
||||
console.log(' Directories:');
|
||||
result.details.directories.forEach(dir => {
|
||||
const dirDisplay = dir || '(root)';
|
||||
const fileCount = result.details.filesByDir[dir].length;
|
||||
console.log(` • ${dirDisplay} (${fileCount} files)`);
|
||||
result.details.filesByDir[dir].forEach(file => {
|
||||
console.log(` - ${path.basename(file)}`);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (result.suggestion) {
|
||||
console.log('\n \x1b[33mSuggested fix (separate commands per directory):\x1b[0m\n');
|
||||
result.suggestion.forEach((cmd, i) => {
|
||||
console.log(` ${i + 1}. ${cmd}`);
|
||||
});
|
||||
console.log('');
|
||||
}
|
||||
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main
|
||||
*/
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
|
||||
if (args.length === 0 || args.includes('--help')) {
|
||||
console.log('Pre-Deployment Validation');
|
||||
console.log('\nUsage:');
|
||||
console.log(' node scripts/validate-deployment.js --command "rsync ..."');
|
||||
console.log('\nExample:');
|
||||
console.log(' node scripts/validate-deployment.js --command "rsync -avz file1 file2/sub/file remote:path"');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const commandIndex = args.indexOf('--command');
|
||||
|
||||
if (commandIndex === -1 || !args[commandIndex + 1]) {
|
||||
console.error('Error: --command flag required');
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
const command = args[commandIndex + 1];
|
||||
const result = validateDeployment(command);
|
||||
const exitCode = displayResults(result);
|
||||
|
||||
process.exit(exitCode);
|
||||
}
|
||||
|
||||
main();
|
||||
|
|
@ -1,12 +1,13 @@
|
|||
/**
|
||||
* Application Configuration
|
||||
* Generic configuration template for Tractatus Framework implementations
|
||||
*/
|
||||
|
||||
module.exports = {
|
||||
// Server
|
||||
port: process.env.PORT || 9000,
|
||||
env: process.env.NODE_ENV || 'development',
|
||||
appName: process.env.APP_NAME || 'Tractatus',
|
||||
appName: process.env.APP_NAME || 'Tractatus Framework',
|
||||
|
||||
// MongoDB
|
||||
mongodb: {
|
||||
|
|
@ -14,30 +15,12 @@ module.exports = {
|
|||
db: process.env.MONGODB_DB || 'tractatus_dev'
|
||||
},
|
||||
|
||||
// JWT
|
||||
jwt: {
|
||||
secret: process.env.JWT_SECRET || 'CHANGE_THIS_IN_PRODUCTION',
|
||||
expiry: process.env.JWT_EXPIRY || '7d'
|
||||
},
|
||||
|
||||
// Admin
|
||||
admin: {
|
||||
email: process.env.ADMIN_EMAIL || 'john.stroh.nz@pm.me'
|
||||
},
|
||||
|
||||
// Logging
|
||||
logging: {
|
||||
level: process.env.LOG_LEVEL || 'info',
|
||||
file: process.env.LOG_FILE || 'logs/app.log'
|
||||
},
|
||||
|
||||
// Feature Flags
|
||||
features: {
|
||||
aiCuration: process.env.ENABLE_AI_CURATION === 'true',
|
||||
mediaTriage: process.env.ENABLE_MEDIA_TRIAGE === 'true',
|
||||
caseSubmissions: process.env.ENABLE_CASE_SUBMISSIONS === 'true'
|
||||
},
|
||||
|
||||
// Security
|
||||
security: {
|
||||
rateLimitWindowMs: parseInt(process.env.RATE_LIMIT_WINDOW_MS || '900000'), // 15 min
|
||||
|
|
|
|||
|
|
@ -1,277 +0,0 @@
|
|||
/**
|
||||
* Currency Configuration
|
||||
* Multi-currency support for Koha donation system
|
||||
*
|
||||
* Exchange rates based on NZD (New Zealand Dollar) as base currency
|
||||
* Update rates periodically or use live API
|
||||
*/
|
||||
|
||||
// Base prices in NZD (in cents)
|
||||
const BASE_PRICES_NZD = {
|
||||
tier_5: 500, // $5 NZD
|
||||
tier_15: 1500, // $15 NZD
|
||||
tier_50: 5000 // $50 NZD
|
||||
};
|
||||
|
||||
// Exchange rates: 1 NZD = X currency
|
||||
// Last updated: 2025-10-08
|
||||
// Source: Manual calculation based on typical rates
|
||||
const EXCHANGE_RATES = {
|
||||
NZD: 1.0, // New Zealand Dollar (base)
|
||||
USD: 0.60, // US Dollar
|
||||
EUR: 0.55, // Euro
|
||||
GBP: 0.47, // British Pound
|
||||
AUD: 0.93, // Australian Dollar
|
||||
CAD: 0.82, // Canadian Dollar
|
||||
JPY: 94.0, // Japanese Yen
|
||||
CHF: 0.53, // Swiss Franc
|
||||
SGD: 0.81, // Singapore Dollar
|
||||
HKD: 4.68 // Hong Kong Dollar
|
||||
};
|
||||
|
||||
// Currency metadata (symbols, formatting, names)
|
||||
const CURRENCY_CONFIG = {
|
||||
NZD: {
|
||||
symbol: '$',
|
||||
code: 'NZD',
|
||||
name: 'NZ Dollar',
|
||||
decimals: 2,
|
||||
locale: 'en-NZ',
|
||||
flag: '🇳🇿'
|
||||
},
|
||||
USD: {
|
||||
symbol: '$',
|
||||
code: 'USD',
|
||||
name: 'US Dollar',
|
||||
decimals: 2,
|
||||
locale: 'en-US',
|
||||
flag: '🇺🇸'
|
||||
},
|
||||
EUR: {
|
||||
symbol: '€',
|
||||
code: 'EUR',
|
||||
name: 'Euro',
|
||||
decimals: 2,
|
||||
locale: 'de-DE',
|
||||
flag: '🇪🇺'
|
||||
},
|
||||
GBP: {
|
||||
symbol: '£',
|
||||
code: 'GBP',
|
||||
name: 'British Pound',
|
||||
decimals: 2,
|
||||
locale: 'en-GB',
|
||||
flag: '🇬🇧'
|
||||
},
|
||||
AUD: {
|
||||
symbol: '$',
|
||||
code: 'AUD',
|
||||
name: 'Australian Dollar',
|
||||
decimals: 2,
|
||||
locale: 'en-AU',
|
||||
flag: '🇦🇺'
|
||||
},
|
||||
CAD: {
|
||||
symbol: '$',
|
||||
code: 'CAD',
|
||||
name: 'Canadian Dollar',
|
||||
decimals: 2,
|
||||
locale: 'en-CA',
|
||||
flag: '🇨🇦'
|
||||
},
|
||||
JPY: {
|
||||
symbol: '¥',
|
||||
code: 'JPY',
|
||||
name: 'Japanese Yen',
|
||||
decimals: 0, // JPY has no decimal places
|
||||
locale: 'ja-JP',
|
||||
flag: '🇯🇵'
|
||||
},
|
||||
CHF: {
|
||||
symbol: 'CHF',
|
||||
code: 'CHF',
|
||||
name: 'Swiss Franc',
|
||||
decimals: 2,
|
||||
locale: 'de-CH',
|
||||
flag: '🇨🇭'
|
||||
},
|
||||
SGD: {
|
||||
symbol: '$',
|
||||
code: 'SGD',
|
||||
name: 'Singapore Dollar',
|
||||
decimals: 2,
|
||||
locale: 'en-SG',
|
||||
flag: '🇸🇬'
|
||||
},
|
||||
HKD: {
|
||||
symbol: '$',
|
||||
code: 'HKD',
|
||||
name: 'Hong Kong Dollar',
|
||||
decimals: 2,
|
||||
locale: 'zh-HK',
|
||||
flag: '🇭🇰'
|
||||
}
|
||||
};
|
||||
|
||||
// Supported currencies list (in display order)
|
||||
const SUPPORTED_CURRENCIES = [
|
||||
'NZD', // Default
|
||||
'USD',
|
||||
'EUR',
|
||||
'GBP',
|
||||
'AUD',
|
||||
'CAD',
|
||||
'JPY',
|
||||
'CHF',
|
||||
'SGD',
|
||||
'HKD'
|
||||
];
|
||||
|
||||
/**
|
||||
* Convert NZD amount to target currency
|
||||
* @param {number} amountNZD - Amount in NZD cents
|
||||
* @param {string} targetCurrency - Target currency code
|
||||
* @returns {number} - Amount in target currency cents
|
||||
*/
|
||||
function convertFromNZD(amountNZD, targetCurrency) {
|
||||
const currency = targetCurrency.toUpperCase();
|
||||
|
||||
if (!EXCHANGE_RATES[currency]) {
|
||||
throw new Error(`Unsupported currency: ${targetCurrency}`);
|
||||
}
|
||||
|
||||
const rate = EXCHANGE_RATES[currency];
|
||||
const converted = Math.round(amountNZD * rate);
|
||||
|
||||
return converted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert any currency amount to NZD
|
||||
* @param {number} amount - Amount in source currency cents
|
||||
* @param {string} sourceCurrency - Source currency code
|
||||
* @returns {number} - Amount in NZD cents
|
||||
*/
|
||||
function convertToNZD(amount, sourceCurrency) {
|
||||
const currency = sourceCurrency.toUpperCase();
|
||||
|
||||
if (!EXCHANGE_RATES[currency]) {
|
||||
throw new Error(`Unsupported currency: ${sourceCurrency}`);
|
||||
}
|
||||
|
||||
const rate = EXCHANGE_RATES[currency];
|
||||
const nzdAmount = Math.round(amount / rate);
|
||||
|
||||
return nzdAmount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get tier prices for a specific currency
|
||||
* @param {string} currency - Currency code
|
||||
* @returns {object} - Tier prices in target currency (cents)
|
||||
*/
|
||||
function getTierPrices(currency) {
|
||||
const tier5 = convertFromNZD(BASE_PRICES_NZD.tier_5, currency);
|
||||
const tier15 = convertFromNZD(BASE_PRICES_NZD.tier_15, currency);
|
||||
const tier50 = convertFromNZD(BASE_PRICES_NZD.tier_50, currency);
|
||||
|
||||
return {
|
||||
tier_5: tier5,
|
||||
tier_15: tier15,
|
||||
tier_50: tier50
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Format currency amount for display
|
||||
* @param {number} amountCents - Amount in cents
|
||||
* @param {string} currency - Currency code
|
||||
* @returns {string} - Formatted currency string (e.g., "$15.00", "¥1,400")
|
||||
*/
|
||||
function formatCurrency(amountCents, currency) {
|
||||
const config = CURRENCY_CONFIG[currency.toUpperCase()];
|
||||
|
||||
if (!config) {
|
||||
throw new Error(`Unsupported currency: ${currency}`);
|
||||
}
|
||||
|
||||
const amount = amountCents / 100; // Convert cents to dollars
|
||||
|
||||
return new Intl.NumberFormat(config.locale, {
|
||||
style: 'currency',
|
||||
currency: currency.toUpperCase(),
|
||||
minimumFractionDigits: config.decimals,
|
||||
maximumFractionDigits: config.decimals
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get currency display name with flag
|
||||
* @param {string} currency - Currency code
|
||||
* @returns {string} - Display name (e.g., "🇺🇸 USD - US Dollar")
|
||||
*/
|
||||
function getCurrencyDisplayName(currency) {
|
||||
const config = CURRENCY_CONFIG[currency.toUpperCase()];
|
||||
|
||||
if (!config) {
|
||||
return currency.toUpperCase();
|
||||
}
|
||||
|
||||
return `${config.flag} ${config.code} - ${config.name}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate currency code
|
||||
* @param {string} currency - Currency code
|
||||
* @returns {boolean} - True if supported
|
||||
*/
|
||||
function isSupportedCurrency(currency) {
|
||||
return SUPPORTED_CURRENCIES.includes(currency.toUpperCase());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get exchange rate for a currency
|
||||
* @param {string} currency - Currency code
|
||||
* @returns {number} - Exchange rate (1 NZD = X currency)
|
||||
*/
|
||||
function getExchangeRate(currency) {
|
||||
return EXCHANGE_RATES[currency.toUpperCase()] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect currency from user location
|
||||
* This is a simplified version - in production, use IP geolocation API
|
||||
* @param {string} countryCode - ISO country code (e.g., 'US', 'GB')
|
||||
* @returns {string} - Suggested currency code
|
||||
*/
|
||||
function getCurrencyForCountry(countryCode) {
|
||||
const countryToCurrency = {
|
||||
'NZ': 'NZD',
|
||||
'US': 'USD',
|
||||
'DE': 'EUR', 'FR': 'EUR', 'IT': 'EUR', 'ES': 'EUR', 'NL': 'EUR',
|
||||
'GB': 'GBP',
|
||||
'AU': 'AUD',
|
||||
'CA': 'CAD',
|
||||
'JP': 'JPY',
|
||||
'CH': 'CHF',
|
||||
'SG': 'SGD',
|
||||
'HK': 'HKD'
|
||||
};
|
||||
|
||||
return countryToCurrency[countryCode.toUpperCase()] || 'NZD'; // Default to NZD
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
BASE_PRICES_NZD,
|
||||
EXCHANGE_RATES,
|
||||
CURRENCY_CONFIG,
|
||||
SUPPORTED_CURRENCIES,
|
||||
convertFromNZD,
|
||||
convertToNZD,
|
||||
getTierPrices,
|
||||
formatCurrency,
|
||||
getCurrencyDisplayName,
|
||||
isSupportedCurrency,
|
||||
getExchangeRate,
|
||||
getCurrencyForCountry
|
||||
};
|
||||
|
|
@ -1,49 +0,0 @@
|
|||
/**
|
||||
* Hooks Metrics API Routes
|
||||
* Serves framework enforcement metrics to admin dashboard
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { authenticateToken, requireAdmin } = require('../middleware/auth.middleware');
|
||||
|
||||
const METRICS_PATH = path.join(__dirname, '../../.claude/metrics/hooks-metrics.json');
|
||||
|
||||
/**
|
||||
* GET /api/admin/hooks/metrics
|
||||
* Get current hooks metrics
|
||||
*/
|
||||
router.get('/metrics', authenticateToken, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
// Check if metrics file exists
|
||||
if (!fs.existsSync(METRICS_PATH)) {
|
||||
return res.json({
|
||||
success: true,
|
||||
metrics: {
|
||||
hook_executions: [],
|
||||
blocks: [],
|
||||
session_stats: {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Read metrics
|
||||
const metricsData = fs.readFileSync(METRICS_PATH, 'utf8');
|
||||
const metrics = JSON.parse(metricsData);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
metrics: metrics
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching hooks metrics:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: 'Failed to fetch hooks metrics'
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
@ -1,147 +1,71 @@
|
|||
/**
|
||||
* Routes Index
|
||||
* Central routing configuration
|
||||
* Central routing configuration for Tractatus Framework API
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
|
||||
// Import route modules
|
||||
const authRoutes = require('./auth.routes');
|
||||
const documentsRoutes = require('./documents.routes');
|
||||
const blogRoutes = require('./blog.routes');
|
||||
const newsletterRoutes = require('./newsletter.routes');
|
||||
const mediaRoutes = require('./media.routes');
|
||||
const casesRoutes = require('./cases.routes');
|
||||
const adminRoutes = require('./admin.routes');
|
||||
const hooksMetricsRoutes = require('./hooks-metrics.routes');
|
||||
const syncHealthRoutes = require('./sync-health.routes');
|
||||
// Import framework route modules
|
||||
const rulesRoutes = require('./rules.routes');
|
||||
const projectsRoutes = require('./projects.routes');
|
||||
const auditRoutes = require('./audit.routes');
|
||||
const governanceRoutes = require('./governance.routes');
|
||||
const kohaRoutes = require('./koha.routes');
|
||||
const demoRoutes = require('./demo.routes');
|
||||
|
||||
// Development/test routes (only in development)
|
||||
if (process.env.NODE_ENV !== 'production') {
|
||||
const testRoutes = require('./test.routes');
|
||||
router.use('/test', testRoutes);
|
||||
}
|
||||
|
||||
// Mount routes
|
||||
router.use('/auth', authRoutes);
|
||||
router.use('/documents', documentsRoutes);
|
||||
router.use('/blog', blogRoutes);
|
||||
router.use('/newsletter', newsletterRoutes);
|
||||
router.use('/media', mediaRoutes);
|
||||
router.use('/cases', casesRoutes);
|
||||
router.use('/admin', adminRoutes);
|
||||
router.use('/admin/hooks', hooksMetricsRoutes);
|
||||
router.use('/admin/sync', syncHealthRoutes);
|
||||
router.use('/admin/rules', rulesRoutes);
|
||||
router.use('/admin/projects', projectsRoutes);
|
||||
router.use('/admin', auditRoutes);
|
||||
// Mount framework routes
|
||||
router.use('/rules', rulesRoutes);
|
||||
router.use('/projects', projectsRoutes);
|
||||
router.use('/audit', auditRoutes);
|
||||
router.use('/governance', governanceRoutes);
|
||||
router.use('/koha', kohaRoutes);
|
||||
router.use('/demo', demoRoutes);
|
||||
|
||||
// API root endpoint - redirect browsers to documentation
|
||||
// API root endpoint
|
||||
router.get('/', (req, res) => {
|
||||
// Check if request is from a browser (Accept: text/html)
|
||||
const acceptsHtml = req.accepts('html');
|
||||
const acceptsJson = req.accepts('json');
|
||||
|
||||
// If browser request, redirect to API documentation page
|
||||
if (acceptsHtml && !acceptsJson) {
|
||||
return res.redirect(302, '/api-reference.html');
|
||||
}
|
||||
|
||||
res.json({
|
||||
name: 'Tractatus AI Safety Framework API',
|
||||
version: '1.0.0',
|
||||
version: '3.5.0',
|
||||
status: 'operational',
|
||||
documentation: 'https://agenticgovernance.digital',
|
||||
endpoints: {
|
||||
auth: {
|
||||
login: 'POST /api/auth/login',
|
||||
me: 'GET /api/auth/me',
|
||||
logout: 'POST /api/auth/logout'
|
||||
},
|
||||
documents: {
|
||||
list: 'GET /api/documents',
|
||||
get: 'GET /api/documents/:identifier',
|
||||
search: 'GET /api/documents/search?q=query',
|
||||
create: 'POST /api/documents (admin)',
|
||||
update: 'PUT /api/documents/:id (admin)',
|
||||
delete: 'DELETE /api/documents/:id (admin)'
|
||||
},
|
||||
blog: {
|
||||
list: 'GET /api/blog',
|
||||
get: 'GET /api/blog/:slug',
|
||||
create: 'POST /api/blog (admin)',
|
||||
update: 'PUT /api/blog/:id (admin)',
|
||||
publish: 'POST /api/blog/:id/publish (admin)',
|
||||
delete: 'DELETE /api/blog/:id (admin)',
|
||||
admin_list: 'GET /api/blog/admin/posts?status=draft (admin)',
|
||||
admin_get: 'GET /api/blog/admin/:id (admin)',
|
||||
suggest_topics: 'POST /api/blog/suggest-topics (admin)'
|
||||
},
|
||||
newsletter: {
|
||||
subscribe: 'POST /api/newsletter/subscribe',
|
||||
verify: 'GET /api/newsletter/verify/:token',
|
||||
unsubscribe: 'POST /api/newsletter/unsubscribe',
|
||||
preferences: 'PUT /api/newsletter/preferences',
|
||||
stats: 'GET /api/newsletter/admin/stats (admin)',
|
||||
subscriptions: 'GET /api/newsletter/admin/subscriptions (admin)',
|
||||
export: 'GET /api/newsletter/admin/export (admin)',
|
||||
delete: 'DELETE /api/newsletter/admin/subscriptions/:id (admin)'
|
||||
},
|
||||
media: {
|
||||
submit: 'POST /api/media/inquiries',
|
||||
list: 'GET /api/media/inquiries (admin)',
|
||||
urgent: 'GET /api/media/inquiries/urgent (admin)',
|
||||
get: 'GET /api/media/inquiries/:id (admin)',
|
||||
assign: 'POST /api/media/inquiries/:id/assign (admin)',
|
||||
respond: 'POST /api/media/inquiries/:id/respond (admin)',
|
||||
delete: 'DELETE /api/media/inquiries/:id (admin)'
|
||||
},
|
||||
cases: {
|
||||
submit: 'POST /api/cases/submit',
|
||||
list: 'GET /api/cases/submissions (admin)',
|
||||
high_relevance: 'GET /api/cases/submissions/high-relevance (admin)',
|
||||
get: 'GET /api/cases/submissions/:id (admin)',
|
||||
approve: 'POST /api/cases/submissions/:id/approve (admin)',
|
||||
reject: 'POST /api/cases/submissions/:id/reject (admin)',
|
||||
request_info: 'POST /api/cases/submissions/:id/request-info (admin)',
|
||||
delete: 'DELETE /api/cases/submissions/:id (admin)'
|
||||
},
|
||||
admin: {
|
||||
moderation_queue: 'GET /api/admin/moderation',
|
||||
moderation_item: 'GET /api/admin/moderation/:id',
|
||||
review: 'POST /api/admin/moderation/:id/review',
|
||||
stats: 'GET /api/admin/stats',
|
||||
activity: 'GET /api/admin/activity'
|
||||
},
|
||||
governance: {
|
||||
status: 'GET /api/governance',
|
||||
classify: 'POST /api/governance/classify (admin)',
|
||||
validate: 'POST /api/governance/validate (admin)',
|
||||
enforce: 'POST /api/governance/enforce (admin)',
|
||||
pressure: 'POST /api/governance/pressure (admin)',
|
||||
verify: 'POST /api/governance/verify (admin)'
|
||||
classify: 'POST /api/governance/classify',
|
||||
validate: 'POST /api/governance/validate',
|
||||
enforce: 'POST /api/governance/enforce',
|
||||
pressure: 'POST /api/governance/pressure',
|
||||
verify: 'POST /api/governance/verify'
|
||||
},
|
||||
koha: {
|
||||
checkout: 'POST /api/koha/checkout',
|
||||
webhook: 'POST /api/koha/webhook',
|
||||
transparency: 'GET /api/koha/transparency',
|
||||
cancel: 'POST /api/koha/cancel',
|
||||
verify: 'GET /api/koha/verify/:sessionId',
|
||||
statistics: 'GET /api/koha/statistics (admin)'
|
||||
rules: {
|
||||
list: 'GET /api/rules',
|
||||
get: 'GET /api/rules/:id',
|
||||
create: 'POST /api/rules',
|
||||
update: 'PUT /api/rules/:id',
|
||||
delete: 'DELETE /api/rules/:id',
|
||||
search: 'GET /api/rules/search'
|
||||
},
|
||||
projects: {
|
||||
list: 'GET /api/projects',
|
||||
get: 'GET /api/projects/:id',
|
||||
create: 'POST /api/projects',
|
||||
update: 'PUT /api/projects/:id',
|
||||
delete: 'DELETE /api/projects/:id'
|
||||
},
|
||||
audit: {
|
||||
logs: 'GET /api/audit/logs',
|
||||
stats: 'GET /api/audit/stats'
|
||||
}
|
||||
},
|
||||
framework: 'Tractatus-Based LLM Safety Architecture',
|
||||
documentation: '/api/docs',
|
||||
health: '/health'
|
||||
framework: {
|
||||
name: 'Tractatus Framework',
|
||||
description: 'AI governance framework enforcing architectural safety constraints at runtime',
|
||||
services: [
|
||||
'InstructionPersistenceClassifier',
|
||||
'CrossReferenceValidator',
|
||||
'BoundaryEnforcer',
|
||||
'ContextPressureMonitor',
|
||||
'MetacognitiveVerifier',
|
||||
'PluralisticDeliberationOrchestrator'
|
||||
]
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,124 +0,0 @@
|
|||
/**
|
||||
* Sync Health Check Routes
|
||||
* Monitors synchronization between file-based instructions and MongoDB
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { authenticateToken, requireAdmin } = require('../middleware/auth.middleware');
|
||||
const GovernanceRule = require('../models/GovernanceRule.model');
|
||||
|
||||
const INSTRUCTION_FILE = path.join(__dirname, '../../.claude/instruction-history.json');
|
||||
|
||||
/**
|
||||
* GET /api/admin/sync/health
|
||||
* Check synchronization health between file and database
|
||||
*/
|
||||
router.get('/health', authenticateToken, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
let fileInstructions = [];
|
||||
let fileError = null;
|
||||
|
||||
if (fs.existsSync(INSTRUCTION_FILE)) {
|
||||
try {
|
||||
const fileData = JSON.parse(fs.readFileSync(INSTRUCTION_FILE, 'utf8'));
|
||||
fileInstructions = (fileData.instructions || []).filter(i => i.active !== false);
|
||||
} catch (err) {
|
||||
fileError = err.message;
|
||||
}
|
||||
} else {
|
||||
fileError = 'File not found';
|
||||
}
|
||||
|
||||
const dbRules = await GovernanceRule.find({ active: true }).lean();
|
||||
const fileCount = fileInstructions.length;
|
||||
const dbCount = dbRules.length;
|
||||
const difference = Math.abs(fileCount - dbCount);
|
||||
const diffPercent = fileCount > 0 ? ((difference / fileCount) * 100).toFixed(1) : 0;
|
||||
|
||||
let status = 'healthy';
|
||||
let message = 'File and database are synchronized';
|
||||
let severity = 'success';
|
||||
|
||||
if (fileError) {
|
||||
status = 'error';
|
||||
message = 'Cannot read instruction file: ' + fileError;
|
||||
severity = 'error';
|
||||
} else if (difference === 0) {
|
||||
status = 'healthy';
|
||||
message = 'Perfectly synchronized';
|
||||
severity = 'success';
|
||||
} else if (difference <= 2) {
|
||||
status = 'warning';
|
||||
message = 'Minor desync: ' + difference + ' instruction' + (difference !== 1 ? 's' : '') + ' differ';
|
||||
severity = 'warning';
|
||||
} else if (difference <= 5) {
|
||||
status = 'warning';
|
||||
message = 'Moderate desync: ' + difference + ' instructions differ (' + diffPercent + '%)';
|
||||
severity = 'warning';
|
||||
} else {
|
||||
status = 'critical';
|
||||
message = 'Critical desync: ' + difference + ' instructions differ (' + diffPercent + '%)';
|
||||
severity = 'error';
|
||||
}
|
||||
|
||||
const fileIds = new Set(fileInstructions.map(i => i.id));
|
||||
const dbIds = new Set(dbRules.map(r => r.id));
|
||||
|
||||
const missingInDb = fileInstructions
|
||||
.filter(i => !dbIds.has(i.id))
|
||||
.map(i => ({ id: i.id, text: i.text.substring(0, 60) + '...' }));
|
||||
|
||||
const orphanedInDb = dbRules
|
||||
.filter(r => !fileIds.has(r.id))
|
||||
.map(r => ({ id: r.id, text: r.text.substring(0, 60) + '...' }));
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
health: {
|
||||
status,
|
||||
message,
|
||||
severity,
|
||||
timestamp: new Date().toISOString(),
|
||||
counts: { file: fileCount, database: dbCount, difference, differencePercent: parseFloat(diffPercent) },
|
||||
details: { missingInDatabase: missingInDb, orphanedInDatabase: orphanedInDb },
|
||||
recommendations: difference > 0 ? [
|
||||
'Run: node scripts/sync-instructions-to-db.js --force',
|
||||
'Or restart the server (auto-sync on startup)',
|
||||
'Or wait for next session initialization'
|
||||
] : []
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Sync health check error:', error);
|
||||
res.status(500).json({ success: false, error: 'Failed to check sync health', message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/admin/sync/trigger
|
||||
* Manually trigger synchronization
|
||||
*/
|
||||
router.post('/trigger', authenticateToken, requireAdmin, async (req, res) => {
|
||||
try {
|
||||
const { syncInstructions } = require('../../scripts/sync-instructions-to-db.js');
|
||||
const result = await syncInstructions({ silent: true });
|
||||
|
||||
if (result.success) {
|
||||
res.json({
|
||||
success: true,
|
||||
message: 'Synchronization completed successfully',
|
||||
result: { added: result.added, updated: result.updated, deactivated: result.deactivated, finalCount: result.finalCount }
|
||||
});
|
||||
} else {
|
||||
res.status(500).json({ success: false, error: 'Synchronization failed', message: result.error || 'Unknown error' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Manual sync trigger error:', error);
|
||||
res.status(500).json({ success: false, error: 'Failed to trigger synchronization', message: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
/**
|
||||
* Security Event Logger (inst_046 - Quick Win Version)
|
||||
* Centralized logging for all security events
|
||||
*
|
||||
* QUICK WIN: Simple file-based logging with JSON format
|
||||
* Full version in Phase 5 will add ProtonMail/Signal alerts
|
||||
*/
|
||||
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
|
||||
const SECURITY_LOG_PATH = process.env.SECURITY_LOG_PATH ||
|
||||
(process.env.HOME ? `${process.env.HOME}/var/log/tractatus/security-audit.log` : '/var/log/tractatus/security-audit.log');
|
||||
|
||||
/**
|
||||
* Log a security event to audit trail
|
||||
*
|
||||
* @param {Object} event - Security event details
|
||||
* @param {string} event.type - Event type (e.g., 'rate_limit_violation')
|
||||
* @param {string} event.sourceIp - Source IP address
|
||||
* @param {string} event.userId - User ID (if authenticated)
|
||||
* @param {string} event.endpoint - Request endpoint
|
||||
* @param {string} event.userAgent - User agent string
|
||||
* @param {Object} event.details - Additional event details
|
||||
* @param {string} event.action - Action taken (e.g., 'blocked', 'logged')
|
||||
* @param {string} event.severity - Severity level ('low', 'medium', 'high', 'critical')
|
||||
*/
|
||||
async function logSecurityEvent(event) {
|
||||
const logEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
event_type: event.type || 'unknown',
|
||||
source_ip: event.sourceIp || 'unknown',
|
||||
user_id: event.userId || 'anonymous',
|
||||
endpoint: event.endpoint || 'unknown',
|
||||
user_agent: event.userAgent || 'unknown',
|
||||
violation_details: event.details || {},
|
||||
action_taken: event.action || 'logged',
|
||||
severity: event.severity || 'medium'
|
||||
};
|
||||
|
||||
const logLine = JSON.stringify(logEntry) + '\n';
|
||||
|
||||
try {
|
||||
// Ensure log directory exists
|
||||
const logDir = path.dirname(SECURITY_LOG_PATH);
|
||||
await fs.mkdir(logDir, { recursive: true, mode: 0o750 });
|
||||
|
||||
// Append to log file
|
||||
await fs.appendFile(SECURITY_LOG_PATH, logLine, { encoding: 'utf-8' });
|
||||
} catch (error) {
|
||||
// Fallback to console if file logging fails
|
||||
console.error('[SECURITY LOGGER ERROR]', error.message);
|
||||
console.error('[SECURITY EVENT]', logEntry);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Extract client IP from request (handles proxies)
|
||||
*/
|
||||
function getClientIp(req) {
|
||||
return (
|
||||
req.ip ||
|
||||
req.headers['x-forwarded-for']?.split(',')[0]?.trim() ||
|
||||
req.connection.remoteAddress ||
|
||||
'unknown'
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
logSecurityEvent,
|
||||
getClientIp
|
||||
};
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
[Unit]
|
||||
Description=Tractatus AI Safety Framework (Development)
|
||||
Documentation=https://tractatus.sydigital.co.nz
|
||||
After=network.target mongod.service
|
||||
Wants=mongod.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=theflow
|
||||
Group=theflow
|
||||
WorkingDirectory=/home/theflow/projects/tractatus
|
||||
|
||||
# Environment
|
||||
Environment=NODE_ENV=development
|
||||
Environment=PORT=9000
|
||||
EnvironmentFile=/home/theflow/projects/tractatus/.env
|
||||
|
||||
# Execution
|
||||
ExecStart=/usr/bin/node src/server.js
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
# Security hardening
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=read-only
|
||||
ReadWritePaths=/home/theflow/projects/tractatus/logs
|
||||
ReadWritePaths=/home/theflow/projects/tractatus/uploads
|
||||
|
||||
# Logging
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=tractatus-dev
|
||||
|
||||
# Resource limits
|
||||
LimitNOFILE=65536
|
||||
MemoryLimit=1G
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
[Unit]
|
||||
Description=Tractatus AI Safety Framework (Production)
|
||||
Documentation=https://tractatus.sydigital.co.nz
|
||||
After=network.target mongod.service
|
||||
Wants=mongod.service
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=ubuntu
|
||||
Group=ubuntu
|
||||
WorkingDirectory=/var/www/tractatus
|
||||
|
||||
# Environment
|
||||
Environment=NODE_ENV=production
|
||||
Environment=PORT=9000
|
||||
EnvironmentFile=/var/www/tractatus/.env
|
||||
|
||||
# Execution
|
||||
ExecStart=/usr/bin/node src/server.js
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
|
||||
# Security hardening
|
||||
NoNewPrivileges=true
|
||||
PrivateTmp=true
|
||||
ProtectSystem=strict
|
||||
ProtectHome=true
|
||||
ReadWritePaths=/var/www/tractatus/logs
|
||||
ReadWritePaths=/var/www/tractatus/uploads
|
||||
|
||||
# Logging
|
||||
StandardOutput=journal
|
||||
StandardError=journal
|
||||
SyslogIdentifier=tractatus
|
||||
|
||||
# Resource limits
|
||||
LimitNOFILE=65536
|
||||
MemoryLimit=2G
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
Loading…
Add table
Reference in a new issue