clear old assets, new ci/cd flow

This commit is contained in:
2026-05-26 11:54:41 -04:00
parent 82815009c9
commit 72609755f8
87 changed files with 4132 additions and 7158 deletions

334
scripts/generate-tokens.mjs Normal file
View File

@@ -0,0 +1,334 @@
#!/usr/bin/env node
/**
* generate-tokens.mjs
*
* Reads design-tokens/*.json and generates platform-specific code:
* - web/src/theme/tokens.ts
* - iOS/Kordant/Theme/GeneratedTokens.swift
* - android/app/src/main/res/values/generated_tokens.xml
*
* Usage: node scripts/generate-tokens.mjs
*/
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = join(__dirname, "..");
const tokensDir = join(root, "design-tokens");
function load(name) {
return JSON.parse(readFileSync(join(tokensDir, `${name}.json`), "utf-8"));
}
// ─── Helpers ────────────────────────────────────────────────────────────────
function toCamel(key) {
return key.replace(/([A-Z])/g, (_, c) => `_${c}`).toLowerCase();
}
function hexToRgb(hex) {
const h = hex.replace("#", "");
return {
r: parseInt(h.substring(0, 2), 16),
g: parseInt(h.substring(2, 4), 16),
b: parseInt(h.substring(4, 6), 16),
};
}
// ─── Web (TypeScript) ──────────────────────────────────────────────────────
function generateWebTokens(colors, typography, spacing, shadows, radius) {
const lines = [
"// Auto-generated from design-tokens/*.json — DO NOT EDIT MANUALLY",
"// Run: node scripts/generate-tokens.mjs",
"",
];
// Colors
lines.push("export const tokenColors = {");
// Brand
lines.push(" brand: {");
for (const [key, val] of Object.entries(colors.brand)) {
lines.push(` ${toCamel(key)}: "${val.value}",`);
}
lines.push(" },");
// Semantic
lines.push(" semantic: {");
for (const [key, val] of Object.entries(colors.semantic)) {
if (typeof val === "string") {
lines.push(` ${toCamel(key)}: "${val}",`);
} else if (val.light !== undefined) {
lines.push(` ${toCamel(key)}: { light: "${val.light}", dark: "${val.dark}" },`);
}
}
lines.push(" },");
// Background
lines.push(" background: {");
for (const [key, val] of Object.entries(colors.background)) {
lines.push(` ${toCamel(key)}: { light: "${val.light}", dark: "${val.dark}" },`);
}
lines.push(" },");
// Text
lines.push(" text: {");
for (const [key, val] of Object.entries(colors.text)) {
lines.push(` ${toCamel(key)}: { light: "${val.light}", dark: "${val.dark}" },`);
}
lines.push(" },");
// Border
lines.push(" border: {");
for (const [key, val] of Object.entries(colors.border)) {
lines.push(` ${toCamel(key)}: { light: "${val.light}", dark: "${val.dark}" },`);
}
lines.push(" },");
lines.push("};");
// Typography
lines.push("");
lines.push("export const tokenTypography = {");
lines.push(` fontFamily: "${typography.fontFamily.value}",`);
lines.push(` fallback: "${typography.fontFamily.fallback}",`);
lines.push(" scale: {");
for (const [key, val] of Object.entries(typography.scale)) {
lines.push(` ${toCamel(key)}: { size: "${val.size}", lineHeight: "${val.lineHeight}" },`);
}
lines.push(" },");
lines.push(" weights: {");
for (const [key, val] of Object.entries(typography.weights)) {
lines.push(` ${key}: ${val.value},`);
}
lines.push(" },");
lines.push("};");
// Spacing
lines.push("");
lines.push("export const tokenSpacing = {");
for (const [key, val] of Object.entries(spacing.scale)) {
lines.push(` ${key}: "${val.value}",`);
}
lines.push("};");
// Shadows
lines.push("");
lines.push("export const tokenShadows = {");
for (const [key, val] of Object.entries(shadows.scale)) {
const shadow = `${val.x}px ${val.y}px ${val.blur}px ${val.spread}px ${val.color}`;
lines.push(` ${key}: "${shadow}",`);
}
lines.push("};");
// Radius
lines.push("");
lines.push("export const tokenRadius = {");
for (const [key, val] of Object.entries(radius.scale)) {
lines.push(` ${key}: "${val.value}",`);
}
lines.push("};");
return lines.join("\n") + "\n";
}
// ─── iOS (Swift) ────────────────────────────────────────────────────────────
function generateSwiftTokens(colors, typography, spacing) {
const lines = [
"// Auto-generated from design-tokens/*.json — DO NOT EDIT MANUALLY",
"// Run: node scripts/generate-tokens.mjs",
"",
"import SwiftUI",
"",
];
// Color extension
lines.push("extension Color {");
lines.push(" // MARK: - Brand");
for (const [key, val] of Object.entries(colors.brand)) {
const { r, g, b } = hexToRgb(val.value);
const swiftKey = key.charAt(0).toLowerCase() + key.slice(1);
lines.push(
` static let ${swiftKey} = Color(red: ${r} / 255, green: ${g} / 255, blue: ${b} / 255)`
);
}
lines.push("");
lines.push(" // MARK: - Semantic");
for (const [key, val] of Object.entries(colors.semantic)) {
if (val.value) {
const { r, g, b } = hexToRgb(val.value);
const swiftKey = key.charAt(0).toLowerCase() + key.slice(1);
lines.push(
` static let ${swiftKey} = Color(red: ${r} / 255, green: ${g} / 255, blue: ${b} / 255)`
);
}
}
lines.push("}");
// AdaptiveColor helper
lines.push("");
lines.push("extension UIColor {");
lines.push(" convenience init(hex: String) {");
lines.push(
' var hexSanitized = hex.trimmingCharacters(in: .whitespacesAndNewlines)'
);
lines.push(
' hexSanitized = hexSanitized.replacingOccurrences(of: "#", with: "")'
);
lines.push(" var rgb: UInt64 = 0");
lines.push(" Scanner(string: hexSanitized).scanHexInt64(&rgb)");
lines.push(" let r = CGFloat((rgb & 0xFF0000) >> 16) / 255.0");
lines.push(" let g = CGFloat((rgb & 0x00FF00) >> 8) / 255.0");
lines.push(" let b = CGFloat(rgb & 0x0000FF) / 255.0");
lines.push(" self.init(red: r, green: g, blue: b, alpha: 1.0)");
lines.push(" }");
lines.push("}");
// Spacing
lines.push("");
lines.push("enum DesignTokens {");
lines.push(" enum Spacing {");
for (const [key, val] of Object.entries(spacing.scale)) {
if (key === "0") continue; // skip numeric key — invalid in Swift
const px = parseInt(val.value);
lines.push(` static let ${key}: CGFloat = ${px}`);
}
lines.push(" }");
lines.push("}");
return lines.join("\n") + "\n";
}
// ─── Android (XML) ──────────────────────────────────────────────────────────
function generateAndroidTokens(colors, typography, spacing, shadows, radius) {
const lines = [
'<?xml version="1.0" encoding="utf-8"?>',
"<!-- Auto-generated from design-tokens/*.json — DO NOT EDIT MANUALLY -->",
"<!-- Run: node scripts/generate-tokens.mjs -->",
"<resources>",
];
// Brand colors
lines.push(" <!-- Brand -->");
for (const [key, val] of Object.entries(colors.brand)) {
const name = "brand_" + toCamel(key);
lines.push(` <color name="${name}">${val.value}</color>`);
}
// Semantic colors
lines.push(" <!-- Semantic -->");
for (const [key, val] of Object.entries(colors.semantic)) {
if (typeof val === "string") {
const name = "sem_" + toCamel(key);
lines.push(` <color name="${name}">${val}</color>`);
}
}
// Light theme colors
lines.push(" <!-- Light theme -->");
lines.push(
` <color name="bg_light">${colors.background.bg.light}</color>`
);
lines.push(
` <color name="bg_secondary_light">${colors.background.bgSecondary.light}</color>`
);
lines.push(
` <color name="text_primary_light">${colors.text.textPrimary.light}</color>`
);
lines.push(
` <color name="text_secondary_light">${colors.text.textSecondary.light}</color>`
);
lines.push(
` <color name="border_light">${colors.border.border.light}</color>`
);
// Dark theme colors
lines.push(" <!-- Dark theme -->");
lines.push(
` <color name="bg_dark">${colors.background.bg.dark}</color>`
);
lines.push(
` <color name="bg_secondary_dark">${colors.background.bgSecondary.dark}</color>`
);
lines.push(
` <color name="text_primary_dark">${colors.text.textPrimary.dark}</color>`
);
lines.push(
` <color name="text_secondary_dark">${colors.text.textSecondary.dark}</color>`
);
lines.push(
` <color name="border_dark">${colors.border.border.dark}</color>`
);
// Spacing dimensions
lines.push(" <!-- Spacing -->");
for (const [key, val] of Object.entries(spacing.scale)) {
const dp = val.value.replace("px", "dp");
lines.push(` <dimen name="spacing_${key}">${dp}</dimen>`);
}
// Border radius
lines.push(" <!-- Corner radius -->");
for (const [key, val] of Object.entries(radius.scale)) {
const name = key === "full" ? "corner_full" : `corner_${key}`;
const dp = val.value.replace("px", "dp");
lines.push(` <dimen name="${name}">${dp}</dimen>`);
}
// Font sizes
lines.push(" <!-- Typography -->");
for (const [key, val] of Object.entries(typography.scale)) {
lines.push(` <dimen name="font_${toCamel(key)}">${val.size.replace("px", "sp")}</dimen>`);
lines.push(` <dimen name="font_${toCamel(key)}_lh">${val.lineHeight.replace("px", "sp")}</dimen>`);
}
lines.push("</resources>");
return lines.join("\n") + "\n";
}
// ─── Main ───────────────────────────────────────────────────────────────────
function main() {
const colors = load("colors");
const typography = load("typography");
const spacing = load("spacing");
const shadows = load("shadows");
const radius = load("radius");
// Web
const webPath = join(root, "web", "src", "theme", "tokens.ts");
mkdirSync(dirname(webPath), { recursive: true });
writeFileSync(webPath, generateWebTokens(colors, typography, spacing, shadows, radius));
console.log(`${webPath}`);
// iOS
const iosPath = join(root, "iOS", "Kordant", "Theme", "GeneratedTokens.swift");
mkdirSync(dirname(iosPath), { recursive: true });
writeFileSync(iosPath, generateSwiftTokens(colors, typography, spacing));
console.log(`${iosPath}`);
// Android
const androidPath = join(
root,
"android",
"app",
"src",
"main",
"res",
"values",
"generated_tokens.xml"
);
mkdirSync(dirname(androidPath), { recursive: true });
writeFileSync(androidPath, generateAndroidTokens(colors, typography, spacing, shadows, radius));
console.log(`${androidPath}`);
console.log("🎨 Token generation complete.");
}
main();

View File

@@ -1,48 +0,0 @@
import { Trend, Rate } from 'k6/metrics';
export const errorRate = new Rate('error_rate');
export function getBaseUrl() {
return __ENV.BASE_URL || 'http://localhost:3000';
}
export function getTargetRps() {
return parseInt(__ENV.TARGET_RPS || '500', 10);
}
export function getDuration() {
return __ENV.DURATION || '300s';
}
export function defaultThresholds(p99ms) {
return {
thresholds: {
http_req_duration: [`p(99)<${p99ms}`],
error_rate: ['rate<0.01'],
},
};
}
export function checkResponse(res, expectedStatus = 200) {
const pass = check(res, {
'status is expected': (r) => r.status === expectedStatus,
'response time OK': (r) => r.timings.duration < 5000,
});
errorRate.add(!pass);
return pass;
}
export function randomString(length = 10) {
const chars = 'abcdefghijklmnopqrstuvwxyz0123456789';
let result = '';
for (let i = 0; i < length; i++) {
result += chars.charAt(Math.floor(Math.random() * chars.length));
}
return result;
}
export const autoscaleMetric = new Trend('autoscale_vu_count');
export function recordAutoscaleMetric(vuCount) {
autoscaleMetric.add(vuCount);
}

View File

@@ -1,128 +0,0 @@
#!/usr/bin/env bash
# Combined load test runner for all Kordant services
# Usage: ./run-all.sh [service]
# service: all (default), api, darkwatch, spamshield, voiceprint
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPORT_DIR="${SCRIPT_DIR}/reports"
TIMESTAMP="$(date +%Y%m%d-%H%M%S)"
THRESHOLD_FILE="${REPORT_DIR}/threshold-results.json"
SERVICE="${1:-all}"
mkdir -p "$REPORT_DIR"
BASE_URL="${LOAD_TEST_BASE_URL:-http://localhost:3000}"
TARGET_RPS="${TARGET_RPS:-500}"
DURATION="${DURATION:-300s}"
API_TOKEN="${API_TOKEN:-}"
if [[ -z "$API_TOKEN" ]]; then
echo "⚠️ API_TOKEN not set (load tests will run without auth)"
fi
echo "=== Kordant Combined Load Test ==="
echo "Timestamp: $TIMESTAMP"
echo "Base URL: $BASE_URL"
echo "Target RPS: $TARGET_RPS"
echo "Duration: $DURATION"
echo "Service: $SERVICE"
echo ""
K6_OPTS="--summary-export ${REPORT_DIR}/summary-${TIMESTAMP}.json"
if [[ -n "${K6_CLOUD_TOKEN:-}" ]]; then
K6_OPTS="$K6_OPTS --out cloud"
echo "k6 cloud output: enabled"
fi
declare -A EXIT_CODES
ALL_PASSED=true
SERVICE_ENV="BASE_URL=$BASE_URL TARGET_RPS=$TARGET_RPS DURATION=$DURATION API_TOKEN=$API_TOKEN"
run_service_test() {
local name=$1
local script=$2
local summary_file="${REPORT_DIR}/${name}-summary-${TIMESTAMP}.json"
echo ""
echo "=== Running $name Load Test ==="
local opts="--summary-export $summary_file"
if [[ -n "${K6_CLOUD_TOKEN:-}" ]]; then
opts="$opts --out cloud"
fi
set +e
eval "$SERVICE_ENV" k6 run $opts "$script"
EXIT_CODE=$?
set -e
EXIT_CODES[$name]=$EXIT_CODE
if [[ $EXIT_CODE -ne 0 ]]; then
ALL_PASSED=false
echo "$name load test FAILED (exit code: $EXIT_CODE)"
else
echo "$name load test PASSED"
fi
}
if [[ "$SERVICE" == "all" || "$SERVICE" == "api" ]]; then
run_service_test "api" "${SCRIPT_DIR}/services/api.js"
fi
if [[ "$SERVICE" == "all" || "$SERVICE" == "darkwatch" ]]; then
run_service_test "darkwatch" "${SCRIPT_DIR}/services/darkwatch.js"
fi
if [[ "$SERVICE" == "all" || "$SERVICE" == "spamshield" ]]; then
run_service_test "spamshield" "${SCRIPT_DIR}/services/spamshield.js"
fi
if [[ "$SERVICE" == "all" || "$SERVICE" == "voiceprint" ]]; then
run_service_test "voiceprint" "${SCRIPT_DIR}/services/voiceprint.js"
fi
# Aggregate threshold results from all service summaries
echo ""
echo "=== Load Test Results ==="
# Build threshold-results.json from k6 summary exports
jq -n --arg timestamp "$TIMESTAMP" --arg base_url "$BASE_URL" --arg target_rps "$TARGET_RPS" \
'{timestamp: $timestamp, base_url: $base_url, target_rps: $target_rps, services: {}}' \
> "$THRESHOLD_FILE"
for name in "${!EXIT_CODES[@]}"; do
summary_file="${REPORT_DIR}/${name}-summary-${TIMESTAMP}.json"
if [[ -f "$summary_file" ]]; then
jq --arg name "$name" --argjson exit_code "${EXIT_CODES[$name]}" \
'.services[$name] = {
exitCode: $exit_code,
passed: ($exit_code == 0),
metrics: (input | .metrics // {})
}' \
"$THRESHOLD_FILE" "$summary_file" \
> "${THRESHOLD_FILE}.tmp" && mv "${THRESHOLD_FILE}.tmp" "$THRESHOLD_FILE"
else
jq --arg name "$name" --argjson exit_code "${EXIT_CODES[$name]}" \
'.services[$name] = {exitCode: $exit_code, passed: ($exit_code == 0)}' \
"$THRESHOLD_FILE" > "${THRESHOLD_FILE}.tmp" && mv "${THRESHOLD_FILE}.tmp" "$THRESHOLD_FILE"
fi
done
echo "Threshold results saved to: $THRESHOLD_FILE"
for service in "${!EXIT_CODES[@]}"; do
status="pass"
[[ ${EXIT_CODES[$service]} -ne 0 ]] && status="fail"
echo "$service: $status"
done
echo ""
if $ALL_PASSED; then
echo "✅ All load tests passed"
exit 0
else
echo "❌ Some load tests failed"
exit 1
fi

View File

@@ -1,62 +0,0 @@
import http from 'k6/http';
import { check, group } from 'k6';
import { Rate, Trend } from 'k6/metrics';
import { getBaseUrl, getTargetRps, getDuration, defaultThresholds, checkResponse, randomString } from '../lib/common.js';
const notificationLatency = new Trend('notification_p99');
const correlationLatency = new Trend('correlation_p99');
const TARGET_RPS = getTargetRps();
const DURATION = getDuration();
export const options = {
scenarios: {
sustained_load: {
executor: 'constant-arrival-rate',
duration: DURATION,
rate: TARGET_RPS,
preAllocatedVUs: 20,
maxVUs: 100,
startTime: '0s',
exec: 'default',
tags: { scenario: 'sustained_load' },
},
},
thresholds: {
...defaultThresholds(250).thresholds,
notification_p99: ['p(99)<500'],
correlation_p99: ['p(99)<300'],
},
};
const BASE_URL = getBaseUrl();
const AUTH_TOKEN = __ENV.API_TOKEN || '';
const headers = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${AUTH_TOKEN}`,
};
export default function () {
group('API Health', function () {
const res = http.get(`${BASE_URL}/health`, { headers });
checkResponse(res, 200);
});
group('Notifications', function () {
const payload = JSON.stringify({
userId: `user-${randomString()}`,
channel: 'email',
message: 'Load test notification',
});
const res = http.post(`${BASE_URL}/notifications`, payload, { headers });
checkResponse(res, 200);
notificationLatency.add(res.timings.duration);
});
group('Correlation', function () {
const res = http.get(`${BASE_URL}/correlation/events?limit=10`, { headers });
checkResponse(res, 200);
correlationLatency.add(res.timings.duration);
});
}

View File

@@ -1,69 +0,0 @@
import http from 'k6/http';
import { check, group } from 'k6';
import { Rate, Trend } from 'k6/metrics';
import { getBaseUrl, getTargetRps, getDuration, defaultThresholds, checkResponse, randomString } from '../lib/common.js';
const scanLatency = new Trend('scan_p99');
const watchlistLatency = new Trend('watchlist_p99');
const alertLatency = new Trend('alert_p99');
const TARGET_RPS = getTargetRps();
const DURATION = getDuration();
export const options = {
scenarios: {
sustained_load: {
executor: 'constant-arrival-rate',
duration: DURATION,
rate: TARGET_RPS,
preAllocatedVUs: 20,
maxVUs: 100,
startTime: '0s',
exec: 'default',
tags: { scenario: 'sustained_load' },
},
},
thresholds: {
...defaultThresholds(200).thresholds,
scan_p99: ['p(99)<300'],
watchlist_p99: ['p(99)<200'],
alert_p99: ['p(99)<250'],
},
};
const BASE_URL = getBaseUrl();
const AUTH_TOKEN = __ENV.API_TOKEN || '';
const headers = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${AUTH_TOKEN}`,
};
export default function () {
group('Darkwatch Scan', function () {
const payload = JSON.stringify({
type: 'email',
value: `loadtest-${randomString()}@example.com`,
});
const res = http.post(`${BASE_URL}/darkwatch/scan`, payload, { headers });
checkResponse(res, 200);
scanLatency.add(res.timings.duration);
});
group('Watchlist', function () {
const res = http.get(`${BASE_URL}/watchlist?page=1&limit=20`, { headers });
checkResponse(res, 200);
watchlistLatency.add(res.timings.duration);
});
group('Alerts', function () {
const res = http.get(`${BASE_URL}/alerts?status=open&limit=10`, { headers });
checkResponse(res, 200);
alertLatency.add(res.timings.duration);
});
group('Exposure Check', function () {
const res = http.get(`${BASE_URL}/exposure/summary`, { headers });
checkResponse(res, 200);
});
}

View File

@@ -1,89 +0,0 @@
import http from 'k6/http';
import { check, group } from 'k6';
import { Rate, Trend } from 'k6/metrics';
import { getBaseUrl, defaultThresholds, checkResponse, randomString } from '../lib/common.js';
const smsClassifyP99 = new Trend('sms_classify_p99');
const numberReputationP99 = new Trend('number_reputation_p99');
const callAnalyzeP99 = new Trend('call_analyze_p99');
const TARGET_RPS = getTargetRps();
const DURATION = getDuration();
export const options = {
scenarios: {
sustained_load: {
executor: 'constant-arrival-rate',
duration: DURATION,
rate: TARGET_RPS,
preAllocatedVUs: 20,
maxVUs: 100,
startTime: '0s',
exec: 'default',
tags: { scenario: 'sustained_load' },
},
},
thresholds: {
...defaultThresholds(400).thresholds,
sms_classify_p99: ['p(99)<150'],
number_reputation_p99: ['p(99)<300'],
call_analyze_p99: ['p(99)<400'],
},
};
const BASE_URL = getBaseUrl();
const AUTH_TOKEN = __ENV.API_TOKEN || '';
const headers = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${AUTH_TOKEN}`,
};
export default function () {
group('SMS Text Classification', function () {
const payload = JSON.stringify({
text: `Is this message a spam attempt? ${randomString(16)}`,
});
const res = http.post(`${BASE_URL}/spamshield/sms/classify`, payload, { headers });
checkResponse(res, 200);
smsClassifyP99.add(res.timings.duration);
});
group('Number Reputation Check', function () {
const payload = JSON.stringify({
phoneNumber: `+1555${String(Math.floor(1000000 + Math.random() * 9000000))}`,
});
const res = http.post(`${BASE_URL}/spamshield/number/reputation`, payload, { headers });
checkResponse(res, 200);
numberReputationP99.add(res.timings.duration);
});
group('Call Analysis', function () {
const payload = JSON.stringify({
phoneNumber: `+1555${String(Math.floor(1000000 + Math.random() * 9000000))}`,
callTime: new Date().toISOString(),
});
const res = http.post(`${BASE_URL}/spamshield/call/analyze`, payload, { headers });
checkResponse(res, 200);
callAnalyzeP99.add(res.timings.duration);
});
group('Spam Feedback', function () {
const payload = JSON.stringify({
phoneNumber: `+1555${String(Math.floor(1000000 + Math.random() * 9000000))}`,
isSpam: false,
});
const res = http.post(`${BASE_URL}/spamshield/feedback`, payload, { headers });
check(res, { 'feedback status is 201': (r) => r.status === 201 });
});
group('Spam History', function () {
const res = http.get(`${BASE_URL}/spamshield/history?limit=10`, { headers });
checkResponse(res, 200);
});
group('Spam Statistics', function () {
const res = http.get(`${BASE_URL}/spamshield/statistics`, { headers });
checkResponse(res, 200);
});
}

View File

@@ -1,70 +0,0 @@
import http from 'k6/http';
import { check, group } from 'k6';
import { Rate, Trend } from 'k6/metrics';
import { getBaseUrl, getTargetRps, getDuration, defaultThresholds, checkResponse, randomString } from '../lib/common.js';
const enrollmentLatency = new Trend('enrollment_p99');
const verificationLatency = new Trend('verification_p99');
const modelLatency = new Trend('model_retrieval_p99');
const TARGET_RPS = getTargetRps();
const DURATION = getDuration();
export const options = {
scenarios: {
sustained_load: {
executor: 'constant-arrival-rate',
duration: DURATION,
rate: TARGET_RPS,
preAllocatedVUs: 20,
maxVUs: 100,
startTime: '0s',
exec: 'default',
tags: { scenario: 'sustained_load' },
},
},
thresholds: {
...defaultThresholds(250).thresholds,
enrollment_p99: ['p(99)<500'],
verification_p99: ['p(99)<250'],
model_retrieval_p99: ['p(99)<100'],
},
};
const BASE_URL = getBaseUrl();
const AUTH_TOKEN = __ENV.API_TOKEN || '';
const headers = {
'Content-Type': 'application/json',
'Authorization': `Bearer ${AUTH_TOKEN}`,
};
export default function () {
group('Voiceprint Enrollment', function () {
const payload = JSON.stringify({
userId: `loadtest-${randomString()}`,
audioSample: 'base64-encoded-audio-data-placeholder',
sampleRate: 16000,
});
const res = http.post(`${BASE_URL}/voiceprint/enroll`, payload, { headers });
checkResponse(res, 200);
enrollmentLatency.add(res.timings.duration);
});
group('Voiceprint Verification', function () {
const payload = JSON.stringify({
userId: `loadtest-${randomString()}`,
audioSample: 'base64-encoded-audio-data-placeholder',
sampleRate: 16000,
});
const res = http.post(`${BASE_URL}/voiceprint/verify`, payload, { headers });
checkResponse(res, 200);
verificationLatency.add(res.timings.duration);
});
group('Model Retrieval', function () {
const res = http.get(`${BASE_URL}/voiceprint/model/${randomString()}`, { headers });
checkResponse(res, 200);
modelLatency.add(res.timings.duration);
});
}

View File

@@ -1,176 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
# GA4 Setup Script for Kordant
# Two modes:
# 1. MANUAL: Step-by-step guide for GA web console (no credentials needed)
# 2. AUTOMATED: Creates property + stream via Admin API (requires GCP service account)
#
# Usage:
# ./scripts/setup-ga4.sh # Print manual instructions
# ./scripts/setup-ga4.sh --auto # Automated setup (needs GOOGLE_APPLICATION_CREDENTIALS)
# ./scripts/setup-ga4.sh --env-only # Just print what to put in .env
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_DIR="$(dirname "$SCRIPT_DIR")"
show_manual_guide() {
cat <<'GUIDE'
╔══════════════════════════════════════════════════════════════╗
║ Kordant — Manual GA4 Setup Guide ║
║ ~5 minutes in Google Analytics web console ║
╚══════════════════════════════════════════════════════════════╝
STEP 1 — Create GA4 Property
1. Go to https://analytics.google.com/
2. Admin → Create Property → "Kordant"
3. Set reporting time zone, currency
4. Click "Create"
STEP 2 — Configure Data Stream
1. In the new property: Admin → Data Streams → Add Stream → Web
2. Website URL: https://kordant.ai
3. Stream name: "Kordant Landing Page"
4. Click "Create stream"
5. Copy the Measurement ID (format: G-XXXXXXXXXX)
STEP 3 — Create API Secret
1. In the data stream details: Measurement Protocol API secrets → Create
2. Nickname: "Kordant Backend"
3. Copy the API Secret
STEP 4 — Set Up Conversion Events
1. In GA4: Admin → Conversions → New conversion event
2. Create: "waitlist_signup"
3. Create: "page_view" (auto-tracked by default)
4. Optionally: "conversion" (for tracked conversions)
STEP 5 — Configure Environment
Add to .env (or .env.prod for production):
GA4_MEASUREMENT_ID=G-XXXXXXXXXX
GA4_API_SECRET=<api-secret-from-step-3>
STEP 6 — Verify
curl -X POST "https://www.google-analytics.com/mp/collect?measurement_id=G-XXXXXXXXXX&api_secret=<secret>" \
-H "Content-Type: application/json" \
-d '{"client_id":"test-001","events":[{"name":"page_view"}]}'
GUIDE
}
setup_automated() {
if [ -z "${GOOGLE_APPLICATION_CREDENTIALS:-}" ]; then
echo "ERROR: GOOGLE_APPLICATION_CREDENTIALS not set"
echo "Set it to the path of your GCP service account JSON key"
exit 1
fi
if ! command -v node &>/dev/null; then
echo "ERROR: node is required for automated setup"
exit 1
fi
echo "--- Automated GA4 Setup ---"
echo "Using service account: $GOOGLE_APPLICATION_CREDENTIALS"
# Generate a setup script that uses the Google Admin API
cat > /tmp/setup-ga4-auto.mjs << 'SCRIPT'
import { google } from 'googleapis';
import { readFileSync, writeFileSync } from 'fs';
async function main() {
const creds = JSON.parse(readFileSync(process.env.GOOGLE_APPLICATION_CREDENTIALS, 'utf-8'));
const auth = new google.auth.GoogleAuth({
credentials: creds,
scopes: ['https://www.googleapis.com/auth/analytics.edit'],
});
const analyticsAdmin = google.analyticsadmin({ version: 'v1beta', auth });
// Step 1: Create GA4 property
console.log('Creating GA4 property...');
const property = await analyticsAdmin.properties.create({
requestBody: {
displayName: 'Kordant',
industryCategory: 'TECHNOLOGY',
timeZone: 'America/New_York',
currencyCode: 'USD',
parent: `accounts/${creds.account_id || '103950747'}`, // Replace with actual account ID
},
});
console.log(`Property created: ${property.data.name}`);
// Step 2: Create web data stream
console.log('Creating web data stream...');
const stream = await analyticsAdmin.properties.dataStreams.create({
parent: property.data.name,
requestBody: {
type: 'WEB_DATA_STREAM',
displayName: 'Kordant Landing Page',
webStreamData: {
defaultUri: 'https://kordant.ai',
},
},
});
console.log(`Data stream created: ${stream.data.name}`);
console.log(`Measurement ID: ${stream.data.webStreamData.measurementId}`);
// Step 3: Create conversion events
console.log('Creating conversion events...');
for (const event of ['waitlist_signup']) {
try {
await analyticsAdmin.properties.conversionEvents.create({
parent: property.data.name,
requestBody: { eventName: event },
});
console.log(`Conversion event created: ${event}`);
} catch (e) {
console.log(`Conversion event ${event} may already exist: ${e.message}`);
}
}
// Output results
const output = {
propertyId: property.data.name.replace('properties/', ''),
measurementId: stream.data.webStreamData.measurementId,
streamId: stream.data.name,
streamName: stream.data.displayName,
};
writeFileSync('/tmp/ga4-setup-output.json', JSON.stringify(output, null, 2));
console.log('\nResults saved to /tmp/ga4-setup-output.json');
console.log(JSON.stringify(output, null, 2));
}
main().catch(console.error);
SCRIPT
echo ""
echo "To run the automated setup:"
echo " 1. Update the account_id in the script above"
echo " 2. cd $PROJECT_DIR && node /tmp/setup-ga4-auto.mjs"
echo ""
echo "NOTE: You need to provide the Google Analytics account ID."
echo "Find it at: https://analytics.google.com/ → Admin → Account Settings"
}
show_env_only() {
cat <<'ENV'
Required .env additions for Kordant analytics:
GA4_MEASUREMENT_ID=G-XXXXXXXXXX # From GA4 data stream
GA4_API_SECRET= # From GA4 Measurement Protocol API secrets
MIXPANEL_TOKEN= # Mixpanel project token
MIXPANEL_API_SECRET= # Mixpanel project API secret
ENV
}
case "${1:-}" in
--auto)
setup_automated
;;
--env-only)
show_env_only
;;
*)
show_manual_guide
;;
esac

252
scripts/setup-pan.sh Executable file
View File

@@ -0,0 +1,252 @@
#!/bin/bash
set -euo pipefail
# ─── Kordant Scheduler — Pan Server Setup ─────────────────────
# Usage:
# bash scripts/setup-pan.sh # interactively
# bash scripts/setup-pan.sh user@pan # interactively, remote
# bash scripts/setup-pan.sh -u <url> -d <url> ...
#
# Flags:
# -u, --gitea-url URL Gitea clone URL (skip prompt)
# -d, --db-url URL Turso database URL (skip prompt)
# -t, --db-token TOKEN Turso auth token (skip prompt)
# -k, --hooks-dir DIR Gitea hooks directory (skip prompt; empty=skip hook)
# --host HOST SSH host (default: pan)
# -y, --non-interactive Skip all prompts (requires -u, -d, -t)
# -h, --help Show this message
# ─── Defaults ───────────────────────────────────────────────────
PAN_HOST=""
GITEA_URL=""
DB_URL=""
DB_TOKEN=""
HOOKS_DIR=""
NON_INTERACTIVE=false
REPO_DIR="/opt/kordant"
# ─── Help ───────────────────────────────────────────────────────
usage() {
sed -n 's/^# //p; s/^#$//p' "$0"
exit 0
}
# ─── Parse flags ────────────────────────────────────────────────
while [ $# -gt 0 ]; do
case "$1" in
-u|--gitea-url) GITEA_URL="$2"; shift 2 ;;
-d|--db-url) DB_URL="$2"; shift 2 ;;
-t|--db-token) DB_TOKEN="$2"; shift 2 ;;
-k|--hooks-dir) HOOKS_DIR="$2"; shift 2 ;;
--host) PAN_HOST="$2"; shift 2 ;;
-y|--non-interactive) NON_INTERACTIVE=true; shift ;;
-h|--help) usage ;;
-*)
echo "Unknown option: $1"
usage
;;
*) PAN_HOST="${PAN_HOST:-$1}"; shift ;;
esac
done
PAN_HOST="${PAN_HOST:-pan}"
# ─── Remote detection ───────────────────────────────────────────
if [ "$(hostname)" != "pan" ] && [ "$(hostname -s 2>/dev/null)" != "pan" ]; then
if [ -n "${SSH_CONNECTION:-}" ]; then
echo "Already connected to pan via SSH."
else
echo "Not on pan. Connecting to $PAN_HOST via SSH..."
# Rebuild the flag string to pass through
FLAGS=""
[ -n "$GITEA_URL" ] && FLAGS="$FLAGS -u '$GITEA_URL'"
[ -n "$DB_URL" ] && FLAGS="$FLAGS -d '$DB_URL'"
[ -n "$DB_TOKEN" ] && FLAGS="$FLAGS -t '$DB_TOKEN'"
[ -n "$HOOKS_DIR" ] && FLAGS="$FLAGS -k '$HOOKS_DIR'"
$NON_INTERACTIVE && FLAGS="$FLAGS -y"
scp "$0" "${PAN_HOST}:/tmp/kordant-setup.sh"
ssh -t "$PAN_HOST" "sudo bash /tmp/kordant-setup.sh $FLAGS && rm /tmp/kordant-setup.sh"
exit $?
fi
fi
echo "=== Kordant Scheduler Setup (running on pan) ==="
# ─── Step 1: Install prerequisites ─────────────────────────────
echo "--- Step 1: Checking prerequisites ---"
if ! command -v docker &>/dev/null; then
echo "Installing Docker..."
curl -fsSL https://get.docker.com | bash
systemctl enable --now docker
else
echo "Docker already installed."
fi
if ! docker compose version &>/dev/null; then
echo "Installing Docker Compose plugin..."
apt-get update && apt-get install -y docker-compose-plugin
else
echo "Docker Compose plugin already installed."
fi
if ! command -v git &>/dev/null; then
echo "Installing git..."
apt-get update && apt-get install -y git
else
echo "Git already installed."
fi
# ─── Step 2: Clone or pull the repo ────────────────────────────
echo "--- Step 2: Setting up repo at $REPO_DIR ---"
if [ ! -d "$REPO_DIR/.git" ]; then
if [ -z "$GITEA_URL" ]; then
if $NON_INTERACTIVE; then
echo "❌ --gitea-url is required in non-interactive mode"
exit 1
fi
while [ -z "$GITEA_URL" ]; do
read -rp "Gitea clone URL (e.g. http://localhost:3000/kordant/kordant.git): " GITEA_URL
done
fi
echo "Cloning $GITEA_URL ..."
git clone "$GITEA_URL" "$REPO_DIR"
cd "$REPO_DIR"
else
echo "Repo exists. Pulling latest..."
cd "$REPO_DIR"
git pull
fi
# ─── Step 3: Create .env with credentials ──────────────────────
echo "--- Step 3: Environment file ---"
if [ ! -f "$REPO_DIR/.env" ]; then
if [ -z "$DB_URL" ]; then
if $NON_INTERACTIVE; then
echo "❌ --db-url is required in non-interactive mode"
exit 1
fi
read -rp " DATABASE_URL (e.g. libsql://kordant.turso.io): " DB_URL
fi
if [ -z "$DB_TOKEN" ]; then
if $NON_INTERACTIVE; then
echo "❌ --db-token is required in non-interactive mode"
exit 1
fi
read -rsp " DATABASE_AUTH_TOKEN: " DB_TOKEN
echo ""
fi
cat > "$REPO_DIR/.env" << ENVEOF
# ─── Kordant Scheduler Environment ─────────────────────────────
# Turso database
DATABASE_URL="${DB_URL}"
DATABASE_AUTH_TOKEN="${DB_TOKEN}"
# Job queue (local Redis container)
REDIS_URL="redis://redis:6379"
# Node environment
NODE_ENV=production
JOB_WORKER=true
JOB_PRIMARY=true
ENVEOF
chmod 600 "$REPO_DIR/.env"
echo "✅ Created $REPO_DIR/.env"
else
echo "$REPO_DIR/.env already exists, keeping it."
fi
# ─── Step 4: Gitea post-receive hook ────────────────────────────
echo "--- Step 4: Gitea post-receive hook ---"
if [ -z "$HOOKS_DIR" ] && ! $NON_INTERACTIVE; then
read -rp "Gitea repo hooks directory (or leave blank to skip): " HOOKS_DIR
fi
if [ -n "$HOOKS_DIR" ]; then
if [ ! -d "$HOOKS_DIR" ]; then
echo " Directory not found: $HOOKS_DIR"
if $NON_INTERACTIVE; then
echo "❌ hooks-dir does not exist"
exit 1
fi
else
HOOK_FILE="$HOOKS_DIR/post-receive"
cat > "$HOOK_FILE" << 'HOOKEOF'
#!/bin/bash
cd /opt/kordant
git pull origin main
docker compose -f scheduler/docker-compose.yml up -d --build
HOOKEOF
chmod +x "$HOOK_FILE"
echo "✅ Post-receive hook installed at $HOOK_FILE"
fi
else
echo "Skipping post-receive hook."
fi
# ─── Step 5: Create systemd service ────────────────────────────
echo "--- Step 5: Systemd service ---"
SERVICE_NAME="kordant-scheduler"
SERVICE_FILE="/etc/systemd/system/${SERVICE_NAME}.service"
if [ ! -f "$SERVICE_FILE" ]; then
echo "Creating $SERVICE_FILE..."
cat > "$SERVICE_FILE" << SERVICEEOF
[Unit]
Description=Kordant Background Job Scheduler
After=docker.service network-online.target
Wants=docker.service network-online.target
[Service]
Type=oneshot
RemainAfterExit=yes
WorkingDirectory=$REPO_DIR
ExecStart=docker compose -f scheduler/docker-compose.yml up -d --build
ExecStop=docker compose -f scheduler/docker-compose.yml down
ExecReload=docker compose -f scheduler/docker-compose.yml pull && docker compose -f scheduler/docker-compose.yml up -d --build
StandardOutput=journal
StandardError=journal
[Install]
WantedBy=multi-user.target
SERVICEEOF
systemctl daemon-reload
else
echo "$SERVICE_FILE already exists."
fi
# ─── Step 6: Start / restart the scheduler ─────────────────────
echo "--- Step 6: Starting scheduler ---"
systemctl enable "$SERVICE_NAME" 2>/dev/null || true
cd "$REPO_DIR"
docker compose -f scheduler/docker-compose.yml pull 2>/dev/null || true
docker compose -f scheduler/docker-compose.yml up -d --build
echo ""
echo "=== Scheduler status ==="
sleep 3
docker compose -f scheduler/docker-compose.yml ps
echo ""
echo "=== Kordant scheduler setup complete ==="
echo " Repo: $REPO_DIR"
echo " Logs: journalctl -u kordant-scheduler -f"
echo " Shell: cd $REPO_DIR && docker compose -f scheduler/docker-compose.yml logs -f"
echo " .env: $REPO_DIR/.env"