05ed037fe8
- DNS: pilot.landvex.com -> 16.170.83.169 - TLS: Let's Encrypt certificate (expires 2026-09-30) - Nginx: reverse proxy with SSL termination - API: https://pilot.landvex.com/api/v1/missions - UI: https://pilot.landvex.com/ - Upload: POST /api/v1/missions/import (multipart/form-data) Verified: ✅ https://pilot.landvex.com/health ✅ https://pilot.landvex.com/version ✅ https://pilot.landvex.com/api/v1/missions (list) ✅ https://pilot.landvex.com/api/v1/missions/:id (get) ✅ POST /api/v1/missions/import (video upload) ✅ UI loads with title 'LandveX Intelligence Lab' Next: Pilot 001 — Break the system!
500 lines
17 KiB
JavaScript
500 lines
17 KiB
JavaScript
#!/usr/bin/env node
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// SIL Architecture Policy — Regler som kod
|
|
// Erik-krav: "SIL kan kontrollera att arkitekturregler följs"
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
import { readFileSync, existsSync, writeFileSync, appendFileSync } from 'fs';
|
|
import { execSync } from 'child_process';
|
|
|
|
const GRAPH_PATH = '/home/bernt/.openclaw/workspace/SYSTEM_GRAPH.json';
|
|
const POLICY_LOG = '/home/bernt/.openclaw/workspace/SIL/policy-violations.jsonl';
|
|
|
|
/**
|
|
* Arkitekturregler som kod.
|
|
* Varje regel har:
|
|
* - id: unikt nummer
|
|
* - name: beskrivande namn
|
|
* - severity: CRITICAL, HIGH, MEDIUM, LOW
|
|
* - check: funktion som returnerar true om regeln bryts
|
|
* - message: förklaring av överträdelsen
|
|
*/
|
|
const ARCHITECTURE_RULES = [
|
|
{
|
|
id: 1,
|
|
name: 'UI får aldrig anropa databasen direkt',
|
|
severity: 'CRITICAL',
|
|
category: 'layer_separation',
|
|
check: (graph, files) => {
|
|
// Kontrollera om frontend-filer innehåller databasanrop
|
|
const frontendFiles = files.filter(f =>
|
|
f.match(/\.tsx$|\.jsx$|\.ts$|\.js$/) &&
|
|
(f.includes('frontend') || f.includes('app') || f.includes('ui'))
|
|
);
|
|
|
|
for (const file of frontendFiles) {
|
|
try {
|
|
const content = readFileSync(file, 'utf8');
|
|
if (content.match(/import.*pg|import.*mysql|import.*prisma|new Pool\(|createConnection/i)) {
|
|
return { violated: true, file, evidence: 'Direkt databas-import hittad' };
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
return { violated: false };
|
|
},
|
|
message: 'Frontend-kod får inte innehålla direkta databasanrop. Använd API-lager.'
|
|
},
|
|
{
|
|
id: 2,
|
|
name: 'Domänlogik får inte ligga i API-lagret',
|
|
severity: 'HIGH',
|
|
category: 'layer_separation',
|
|
check: (graph, files) => {
|
|
// Kontrollera om API-handlers innehåller affärslogik
|
|
const apiFiles = files.filter(f =>
|
|
f.match(/handler|controller|route/i) &&
|
|
!f.includes('service')
|
|
);
|
|
|
|
for (const file of apiFiles) {
|
|
try {
|
|
const content = readFileSync(file, 'utf8');
|
|
// Enkel heuristik: om filen är >100 rader och innehåller affärslogik
|
|
const lines = content.split('\n');
|
|
if (lines.length > 100) {
|
|
const businessLogic = content.match(/if.*balance|if.*amount|calculate|validate.*payment/i);
|
|
if (businessLogic) {
|
|
return { violated: true, file, evidence: 'Affärslogik i API-handler' };
|
|
}
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
return { violated: false };
|
|
},
|
|
message: 'Affärslogik ska ligga i service-lagret, inte i API-handlers.'
|
|
},
|
|
{
|
|
id: 3,
|
|
name: 'Alla externa integrationer ska gå genom adapterlager',
|
|
severity: 'HIGH',
|
|
category: 'integration',
|
|
check: (graph, files) => {
|
|
// Kontrollera om externa anrop görs direkt från service
|
|
const serviceFiles = files.filter(f => f.includes('service'));
|
|
|
|
for (const file of serviceFiles) {
|
|
try {
|
|
const content = readFileSync(file, 'utf8');
|
|
// Om filen anropar extern API direkt utan adapter
|
|
const directCall = content.match(/fetch\(.*stripe|fetch\(.*aws|axios\(.*stripe/i);
|
|
const hasAdapter = content.match(/adapter|client|gateway/i);
|
|
|
|
if (directCall && !hasAdapter) {
|
|
return { violated: true, file, evidence: 'Direkt extern anrop utan adapter' };
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
return { violated: false };
|
|
},
|
|
message: 'Externa integrationer ska gå genom adapter/client-lager.'
|
|
},
|
|
{
|
|
id: 4,
|
|
name: 'Alla betalningar måste vara idempotenta',
|
|
severity: 'CRITICAL',
|
|
category: 'payment',
|
|
check: (graph, files) => {
|
|
// Kontrollera betalnings-relaterade filer
|
|
const paymentFiles = files.filter(f =>
|
|
f.match(/payment|stripe|wallet|payout/i)
|
|
);
|
|
|
|
for (const file of paymentFiles) {
|
|
try {
|
|
const content = readFileSync(file, 'utf8');
|
|
// Ska innehålla idempotens-kontroll
|
|
const hasIdempotency = content.match(/idempot|idempotent|duplicate.*check|already.*processed/i);
|
|
|
|
if (!hasIdempotency && content.match(/charge|payment|payout/i)) {
|
|
return { violated: true, file, evidence: 'Betalningsflöde utan idempotens-kontroll' };
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
return { violated: false };
|
|
},
|
|
message: 'Alla betalningsoperationer måste ha idempotens-kontroll.'
|
|
},
|
|
{
|
|
id: 5,
|
|
name: 'Alla KYC-flöden måste loggas',
|
|
severity: 'CRITICAL',
|
|
category: 'compliance',
|
|
check: (graph, files) => {
|
|
// Kontrollera KYC-filer
|
|
const kycFiles = files.filter(f => f.match(/kyc|identity|verify/i));
|
|
|
|
for (const file of kycFiles) {
|
|
try {
|
|
const content = readFileSync(file, 'utf8');
|
|
// Ska innehålla loggning
|
|
const hasLogging = content.match(/log\.|logger\.|audit|console\.log/i);
|
|
|
|
if (!hasLogging) {
|
|
return { violated: true, file, evidence: 'KYC-flöde utan loggning' };
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
return { violated: false };
|
|
},
|
|
message: 'Alla KYC-operationer måste loggas för compliance.'
|
|
},
|
|
{
|
|
id: 6,
|
|
name: 'Alla nya API:er ska ha integrationstester',
|
|
severity: 'HIGH',
|
|
category: 'testing',
|
|
check: (graph, files) => {
|
|
// Kontrollera om nya endpoints har tester
|
|
const newEndpoints = files.filter(f =>
|
|
f.match(/route|endpoint|handler/i) &&
|
|
!f.match(/\.test\.|\.spec\./i)
|
|
);
|
|
|
|
for (const endpoint of newEndpoints) {
|
|
const testFile = endpoint.replace(/\.(ts|js)$/, '.test.$1');
|
|
if (!existsSync(testFile)) {
|
|
return { violated: true, file: endpoint, evidence: 'Saknar integrationstest' };
|
|
}
|
|
}
|
|
|
|
return { violated: false };
|
|
},
|
|
message: 'Varje ny endpoint måste ha ett integrationstest.'
|
|
},
|
|
{
|
|
id: 7,
|
|
name: 'Kritiska tjänster ska ha circuit breaker',
|
|
severity: 'HIGH',
|
|
category: 'resilience',
|
|
check: (graph, files) => {
|
|
const criticalServices = ['wallet', 'payment', 'auth', 'kyc'];
|
|
|
|
for (const service of criticalServices) {
|
|
const serviceFiles = files.filter(f => f.includes(service));
|
|
|
|
for (const file of serviceFiles) {
|
|
try {
|
|
const content = readFileSync(file, 'utf8');
|
|
const hasCircuitBreaker = content.match(/circuit|circuitbreaker|breaker|fallback/i);
|
|
|
|
if (!hasCircuitBreaker && content.match(/fetch|axios|request/i)) {
|
|
return { violated: true, file, evidence: 'Kritisk tjänst utan circuit breaker' };
|
|
}
|
|
} catch {}
|
|
}
|
|
}
|
|
|
|
return { violated: false };
|
|
},
|
|
message: 'Kritiska tjänster måste ha circuit breaker på externa anrop.'
|
|
},
|
|
{
|
|
id: 8,
|
|
name: 'Ingen hårdkodad konfiguration i källkod',
|
|
severity: 'MEDIUM',
|
|
category: 'configuration',
|
|
check: (graph, files) => {
|
|
const configFiles = files.filter(f =>
|
|
!f.match(/\.env|config|settings|yaml|yml/i)
|
|
);
|
|
|
|
for (const file of configFiles) {
|
|
try {
|
|
const content = readFileSync(file, 'utf8');
|
|
// Hitta hårdkodade värden
|
|
const hardcoded = content.match(/api_key.*=.*['"][a-zA-Z0-9]{20,}['"]|password.*=.*['"][^'"]+['"]/i);
|
|
|
|
if (hardcoded) {
|
|
return { violated: true, file, evidence: 'Hårdkodad konfiguration hittad' };
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
return { violated: false };
|
|
},
|
|
message: 'Konfiguration ska läsas från miljövariabler eller config-filer.'
|
|
},
|
|
{
|
|
id: 9,
|
|
name: 'Databasändringar ska ha rollback-script',
|
|
severity: 'CRITICAL',
|
|
category: 'database',
|
|
check: (graph, files) => {
|
|
const migrationFiles = files.filter(f => f.match(/migration|\.sql$/i));
|
|
|
|
for (const file of migrationFiles) {
|
|
try {
|
|
const content = readFileSync(file, 'utf8');
|
|
// Ska innehålla rollback eller down-migration
|
|
const hasRollback = content.match(/rollback|down|revert|DROP.*IF.*EXISTS/i);
|
|
|
|
if (!hasRollback) {
|
|
return { violated: true, file, evidence: 'Migration utan rollback' };
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
return { violated: false };
|
|
},
|
|
message: 'Varje databasändring måste ha ett rollback-script.'
|
|
},
|
|
{
|
|
id: 10,
|
|
name: 'Alla PR:er ska uppdatera dokumentation',
|
|
severity: 'LOW',
|
|
category: 'documentation',
|
|
check: (graph, files) => {
|
|
// Kontrollera om PR innehåller API-ändringar utan doc-uppdatering
|
|
const apiChanges = files.filter(f => f.match(/openapi|swagger|\.yaml$/i));
|
|
const docChanges = files.filter(f => f.match(/README|CHANGELOG|docs/i));
|
|
|
|
if (apiChanges.length > 0 && docChanges.length === 0) {
|
|
return {
|
|
violated: true,
|
|
file: apiChanges[0],
|
|
evidence: 'API-ändring utan dokumentationsuppdatering'
|
|
};
|
|
}
|
|
|
|
return { violated: false };
|
|
},
|
|
message: 'API-ändringar ska dokumenteras.'
|
|
}
|
|
];
|
|
|
|
class ArchitecturePolicy {
|
|
constructor() {
|
|
this.graph = this.loadGraph();
|
|
this.rules = ARCHITECTURE_RULES;
|
|
}
|
|
|
|
loadGraph() {
|
|
try {
|
|
return JSON.parse(readFileSync(GRAPH_PATH, 'utf8'));
|
|
} catch {
|
|
return { nodes: [], edges: [] };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Kontrollera alla regler mot en uppsättning filer
|
|
*/
|
|
checkPR(files, prInfo = {}) {
|
|
console.log(`🔍 Kontrollerar ${files.length} filer mot ${this.rules.length} arkitekturregler...\n`);
|
|
|
|
const violations = [];
|
|
let criticalCount = 0;
|
|
let highCount = 0;
|
|
|
|
for (const rule of this.rules) {
|
|
const result = rule.check(this.graph, files);
|
|
|
|
if (result.violated) {
|
|
violations.push({
|
|
ruleId: rule.id,
|
|
ruleName: rule.name,
|
|
severity: rule.severity,
|
|
category: rule.category,
|
|
file: result.file,
|
|
evidence: result.evidence,
|
|
message: rule.message,
|
|
timestamp: new Date().toISOString()
|
|
});
|
|
|
|
if (rule.severity === 'CRITICAL') criticalCount++;
|
|
if (rule.severity === 'HIGH') highCount++;
|
|
|
|
// Logga överträdelse
|
|
appendFileSync(POLICY_LOG, JSON.stringify({
|
|
...violations[violations.length - 1],
|
|
pr: prInfo
|
|
}) + '\n', 'utf8');
|
|
}
|
|
}
|
|
|
|
const report = {
|
|
timestamp: new Date().toISOString(),
|
|
pr: prInfo,
|
|
summary: {
|
|
totalRules: this.rules.length,
|
|
violations: violations.length,
|
|
critical: criticalCount,
|
|
high: highCount,
|
|
medium: violations.filter(v => v.severity === 'MEDIUM').length,
|
|
low: violations.filter(v => v.severity === 'LOW').length
|
|
},
|
|
violations,
|
|
passed: violations.length === 0,
|
|
// Kan merge blockeras?
|
|
blockMerge: criticalCount > 0,
|
|
requiresReview: highCount > 0
|
|
};
|
|
|
|
this.printReport(report);
|
|
return report;
|
|
}
|
|
|
|
/**
|
|
* Kontrollera regler för ett repo
|
|
*/
|
|
checkRepo(repoPath) {
|
|
// Hämta alla källkodsfiler
|
|
try {
|
|
const files = execSync(
|
|
`find ${repoPath} -type f \( -name "*.ts" -o -name "*.js" -o -name "*.tsx" -o -name "*.jsx" -o -name "*.sql" -o -name "*.yaml" -o -name "*.yml" \) | head -100`,
|
|
{ encoding: 'utf8' }
|
|
).trim().split('\n').filter(f => f);
|
|
|
|
return this.checkPR(files, { repo: repoPath, type: 'full_scan' });
|
|
} catch (e) {
|
|
console.error('Fel vid repo-scan:', e.message);
|
|
return { error: e.message };
|
|
}
|
|
}
|
|
|
|
printReport(report) {
|
|
console.log('╔═══════════════════════════════════════════════════════════════╗');
|
|
console.log('║ ARKITEKTURPOLICY-KONTROLL ║');
|
|
console.log('╚═══════════════════════════════════════════════════════════════╝');
|
|
console.log();
|
|
|
|
console.log('📊 SAMMANFATTNING\n');
|
|
console.log(` Regler kontrollerade: ${report.summary.totalRules}`);
|
|
console.log(` Överträdelser: ${report.summary.violations}`);
|
|
console.log(` 🔴 Critical: ${report.summary.critical}`);
|
|
console.log(` 🟠 High: ${report.summary.high}`);
|
|
console.log(` 🟡 Medium: ${report.summary.medium}`);
|
|
console.log(` 🟢 Low: ${report.summary.low}`);
|
|
console.log();
|
|
|
|
if (report.violations.length > 0) {
|
|
console.log('⚠️ ÖVERTRÄDELSER\n');
|
|
for (const v of report.violations) {
|
|
const icon = v.severity === 'CRITICAL' ? '🔴' :
|
|
v.severity === 'HIGH' ? '🟠' :
|
|
v.severity === 'MEDIUM' ? '🟡' : '🟢';
|
|
console.log(` ${icon} Regel ${v.ruleId}: ${v.ruleName}`);
|
|
console.log(` Fil: ${v.file}`);
|
|
console.log(` Bevis: ${v.evidence}`);
|
|
console.log(` ${v.message}`);
|
|
console.log();
|
|
}
|
|
} else {
|
|
console.log('✅ Inga överträdelser hittade!\n');
|
|
}
|
|
|
|
console.log('⚡ BESLUT\n');
|
|
if (report.blockMerge) {
|
|
console.log(' 🔴 BLOCKERA MERGE — Kritiska överträdelser måste åtgärdas');
|
|
} else if (report.requiresReview) {
|
|
console.log(' 🟠 KRÄV REVIEW — Högprioriterade överträdelser bör granskas');
|
|
} else {
|
|
console.log(' ✅ GODKÄNN — Inga blockerande överträdelser');
|
|
}
|
|
console.log();
|
|
|
|
console.log('═══════════════════════════════════════════════════════════════\n');
|
|
}
|
|
|
|
/**
|
|
* Lista alla regler
|
|
*/
|
|
listRules() {
|
|
console.log('📋 ARKITEKTURREGLER\n');
|
|
for (const rule of this.rules) {
|
|
const icon = rule.severity === 'CRITICAL' ? '🔴' :
|
|
rule.severity === 'HIGH' ? '🟠' :
|
|
rule.severity === 'MEDIUM' ? '🟡' : '🟢';
|
|
console.log(` ${icon} Regel ${rule.id}: ${rule.name}`);
|
|
console.log(` Kategori: ${rule.category}`);
|
|
console.log(` Allvarlighet: ${rule.severity}`);
|
|
console.log();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Hämta statistik över överträdelser
|
|
*/
|
|
getViolationStats() {
|
|
if (!existsSync(POLICY_LOG)) {
|
|
return { error: 'Inga överträdelser loggade ännu' };
|
|
}
|
|
|
|
const violations = readFileSync(POLICY_LOG, 'utf8')
|
|
.split('\n')
|
|
.filter(line => line.trim())
|
|
.map(line => {
|
|
try { return JSON.parse(line); } catch { return null; }
|
|
})
|
|
.filter(Boolean);
|
|
|
|
const byRule = {};
|
|
const bySeverity = {};
|
|
const byFile = {};
|
|
|
|
for (const v of violations) {
|
|
byRule[v.ruleId] = (byRule[v.ruleId] || 0) + 1;
|
|
bySeverity[v.severity] = (bySeverity[v.severity] || 0) + 1;
|
|
byFile[v.file] = (byFile[v.file] || 0) + 1;
|
|
}
|
|
|
|
return {
|
|
total: violations.length,
|
|
byRule: Object.entries(byRule).map(([id, count]) => ({
|
|
ruleId: id,
|
|
ruleName: this.rules.find(r => r.id == id)?.name || 'Unknown',
|
|
count
|
|
})).sort((a, b) => b.count - a.count),
|
|
bySeverity,
|
|
topFiles: Object.entries(byFile)
|
|
.sort((a, b) => b[1] - a[1])
|
|
.slice(0, 10)
|
|
.map(([file, count]) => ({ file, count }))
|
|
};
|
|
}
|
|
}
|
|
|
|
// ── Main ──────────────────────────────────────────────────────────────────
|
|
|
|
const policy = new ArchitecturePolicy();
|
|
const command = process.argv[2] || '--check';
|
|
|
|
if (command === '--check') {
|
|
const repoPath = process.argv[3] || '/home/bernt/repos/quixzoom.com';
|
|
policy.checkRepo(repoPath);
|
|
} else if (command === '--pr') {
|
|
// Simulera PR-kontroll med filer
|
|
const files = process.argv.slice(3);
|
|
if (files.length === 0) {
|
|
console.log('Användning: node architecture-policy.mjs --pr <fil1> <fil2> ...');
|
|
process.exit(1);
|
|
}
|
|
policy.checkPR(files);
|
|
} else if (command === '--list') {
|
|
policy.listRules();
|
|
} else if (command === '--stats') {
|
|
const stats = policy.getViolationStats();
|
|
console.log(JSON.stringify(stats, null, 2));
|
|
} else {
|
|
console.log('Användning:');
|
|
console.log(' node architecture-policy.mjs --check [repo-path] # Skanna hela repo');
|
|
console.log(' node architecture-policy.mjs --pr <fil1> <fil2> # Kontrollera specifika filer');
|
|
console.log(' node architecture-policy.mjs --list # Lista alla regler');
|
|
console.log(' node architecture-policy.mjs --stats # Visa överträdelse-statistik');
|
|
}
|