skopix 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli/ui.js ADDED
@@ -0,0 +1,126 @@
1
+ import chalk from 'chalk';
2
+
3
+ export function formatStep(step, current, total) {
4
+ const statusIcon = step.success ? chalk.green('✓') : chalk.red('✗');
5
+ const confBar = confidenceBar(step.confidence);
6
+ const actionLabel = chalk.cyan.bold(step.action.padEnd(14));
7
+
8
+ if (step.action === 'BATCH' && step.batchResults) {
9
+ const batchInfo = chalk.magenta(`[${step.batchResults.length} actions]`);
10
+ console.log(
11
+ ` ${chalk.dim(`[${current}/${total}]`)} ${statusIcon} ${actionLabel} ${batchInfo}`
12
+ );
13
+ step.batchResults.forEach((r, i) => {
14
+ const subIcon = r.success ? chalk.green('✓') : chalk.red('✗');
15
+ console.log(
16
+ ` ${chalk.dim(`${i + 1}.`)} ${subIcon} ${chalk.cyan(r.action.padEnd(8))} ${chalk.dim('→')} ${chalk.white(truncate(r.target || '—', 50))}`
17
+ );
18
+ });
19
+ } else {
20
+ console.log(
21
+ ` ${chalk.dim(`[${current}/${total}]`)} ${statusIcon} ${actionLabel} ${chalk.dim('→')} ${chalk.white(truncate(step.target || step.value || '—', 45))}`
22
+ );
23
+ }
24
+
25
+ if (step.reasoning) {
26
+ console.log(` ${chalk.dim(' reasoning:')} ${chalk.dim(truncate(step.reasoning, 70))}`);
27
+ }
28
+
29
+ if (step.observation) {
30
+ console.log(` ${chalk.dim(' observation:')} ${chalk.white(truncate(step.observation, 70))}`);
31
+ }
32
+
33
+ if (step.confidence !== undefined) {
34
+ console.log(` ${chalk.dim(' confidence:')} ${confBar} ${chalk.dim(step.confidence + '/10')}`);
35
+ }
36
+
37
+ if (step.issues && step.issues.length > 0) {
38
+ for (const issue of step.issues) {
39
+ const severity = (issue.severity || 'low').toLowerCase();
40
+ const sev = severityColor(severity);
41
+ console.log(` ${chalk.dim(' issue:')} ${sev(`[${severity.toUpperCase()}]`)} ${chalk.yellow(issue.title || '(no title)')}`);
42
+ }
43
+ }
44
+
45
+ if (step.error) {
46
+ console.log(` ${chalk.dim(' error:')} ${chalk.red(step.error)}`);
47
+ }
48
+
49
+ console.log();
50
+ }
51
+
52
+ export function formatIssue(issue, index) {
53
+ const sev = severityColor(issue.severity);
54
+ console.log(` ${chalk.dim(`${index + 1}.`)} ${sev(`[${issue.severity.toUpperCase()}]`)} ${chalk.white.bold(issue.title)}`);
55
+ console.log(` ${chalk.dim(issue.description)}`);
56
+ console.log(` ${chalk.dim('URL:')} ${chalk.cyan(issue.url)}`);
57
+ console.log();
58
+ }
59
+
60
+ export function printSummary({ sessionId, steps, issues, goalAchieved, stuck, duration, reportPath, videoPath, failReason }) {
61
+ const durationStr = formatDuration(duration);
62
+ const status = goalAchieved
63
+ ? chalk.green.bold('PASSED ✓')
64
+ : stuck
65
+ ? chalk.yellow.bold('STUCK ⚠')
66
+ : chalk.red.bold('FAILED ✗');
67
+
68
+ console.log();
69
+ console.log(chalk.cyan('━'.repeat(60)));
70
+ console.log(chalk.white.bold(' SESSION SUMMARY'));
71
+ console.log(chalk.cyan('━'.repeat(60)));
72
+ console.log(` Status: ${status}`);
73
+ if (failReason) {
74
+ console.log(` Reason: ${chalk.dim(failReason)}`);
75
+ }
76
+ console.log(` Session: ${chalk.dim(sessionId)}`);
77
+ console.log(` Steps: ${chalk.white(steps)}`);
78
+ console.log(` Issues: ${issues > 0 ? chalk.red(issues) : chalk.green(issues)}`);
79
+ console.log(` Duration: ${chalk.white(durationStr)}`);
80
+
81
+ if (reportPath) {
82
+ console.log(` Report: ${chalk.cyan(reportPath)}`);
83
+ }
84
+ if (videoPath) {
85
+ console.log(` Video: ${chalk.cyan(videoPath)}`);
86
+ }
87
+
88
+ console.log(chalk.cyan('━'.repeat(60)));
89
+ console.log();
90
+
91
+ if (issues > 0) {
92
+ console.log(chalk.yellow(` ⚠ ${issues} issue(s) detected. Check the report for details.`));
93
+ }
94
+
95
+ if (reportPath) {
96
+ console.log(chalk.dim(`\n Run 'skopix report' to open the latest report in your browser.\n`));
97
+ }
98
+ }
99
+
100
+ function confidenceBar(confidence = 0) {
101
+ const filled = Math.round(confidence / 2);
102
+ const empty = 5 - filled;
103
+ const bar = chalk.green('█'.repeat(filled)) + chalk.dim('░'.repeat(empty));
104
+ return bar;
105
+ }
106
+
107
+ function severityColor(severity) {
108
+ switch ((severity || '').toLowerCase()) {
109
+ case 'critical': return chalk.red.bold;
110
+ case 'high': return chalk.red;
111
+ case 'medium': return chalk.yellow;
112
+ case 'low': return chalk.blue;
113
+ default: return chalk.white;
114
+ }
115
+ }
116
+
117
+ function truncate(str, len) {
118
+ if (!str) return '';
119
+ return str.length > len ? str.slice(0, len - 1) + '…' : str;
120
+ }
121
+
122
+ function formatDuration(ms) {
123
+ if (ms < 1000) return `${ms}ms`;
124
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
125
+ return `${Math.floor(ms / 60000)}m ${Math.round((ms % 60000) / 1000)}s`;
126
+ }
package/core/auth.js ADDED
@@ -0,0 +1,148 @@
1
+ // core/auth.js — password hashing, token generation, secret encryption
2
+ // Uses Node's built-in crypto so no extra dependencies needed.
3
+
4
+ import crypto from 'crypto';
5
+ import { promisify } from 'util';
6
+
7
+ const scryptAsync = promisify(crypto.scrypt);
8
+
9
+ // ─── PASSWORD HASHING ────────────────────────────────────────────────────────
10
+ // Uses scrypt - built into Node, no bcrypt dependency.
11
+ // Format stored: scrypt$N$r$p$salt_hex$hash_hex
12
+ const SCRYPT_PARAMS = { N: 16384, r: 8, p: 1, keyLen: 64 };
13
+
14
+ export async function hashPassword(password) {
15
+ if (typeof password !== 'string' || password.length < 8) {
16
+ throw new Error('Password must be at least 8 characters');
17
+ }
18
+ const salt = crypto.randomBytes(16);
19
+ const derived = await scryptAsync(password, salt, SCRYPT_PARAMS.keyLen, {
20
+ N: SCRYPT_PARAMS.N,
21
+ r: SCRYPT_PARAMS.r,
22
+ p: SCRYPT_PARAMS.p,
23
+ });
24
+ return `scrypt$${SCRYPT_PARAMS.N}$${SCRYPT_PARAMS.r}$${SCRYPT_PARAMS.p}$${salt.toString('hex')}$${derived.toString('hex')}`;
25
+ }
26
+
27
+ export async function verifyPassword(password, stored) {
28
+ try {
29
+ if (typeof stored !== 'string' || !stored.startsWith('scrypt$')) return false;
30
+ const parts = stored.split('$');
31
+ if (parts.length !== 6) return false;
32
+ const [_, nStr, rStr, pStr, saltHex, hashHex] = parts;
33
+ const N = parseInt(nStr, 10);
34
+ const r = parseInt(rStr, 10);
35
+ const p = parseInt(pStr, 10);
36
+ const salt = Buffer.from(saltHex, 'hex');
37
+ const expected = Buffer.from(hashHex, 'hex');
38
+ const derived = await scryptAsync(password, salt, expected.length, { N, r, p });
39
+ return crypto.timingSafeEqual(expected, derived);
40
+ } catch {
41
+ return false;
42
+ }
43
+ }
44
+
45
+ // ─── TOKEN GENERATION ────────────────────────────────────────────────────────
46
+ export function generateSessionToken() {
47
+ return crypto.randomBytes(32).toString('hex');
48
+ }
49
+
50
+ export function generateInviteToken() {
51
+ return crypto.randomBytes(24).toString('hex');
52
+ }
53
+
54
+ export function generateUserId() {
55
+ return 'u_' + crypto.randomBytes(8).toString('hex');
56
+ }
57
+
58
+ // ─── SECRET ENCRYPTION (for per-user API keys, GitHub tokens, etc) ───────────
59
+ // Uses AES-256-GCM with a key derived from SKOPIX_SECRET_KEY env var.
60
+ // This is for protecting tokens at rest in the DB.
61
+
62
+ function _getMasterKey() {
63
+ const secret = process.env.SKOPIX_SECRET_KEY;
64
+ if (!secret || secret.length < 16) {
65
+ throw new Error('SKOPIX_SECRET_KEY must be set (at least 16 characters) for encrypted storage');
66
+ }
67
+ // Derive a 32-byte key deterministically from the secret
68
+ return crypto.createHash('sha256').update(secret).digest();
69
+ }
70
+
71
+ export function encryptSecret(plaintext) {
72
+ if (typeof plaintext !== 'string') throw new Error('plaintext must be a string');
73
+ const key = _getMasterKey();
74
+ const iv = crypto.randomBytes(12);
75
+ const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
76
+ const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
77
+ const tag = cipher.getAuthTag();
78
+ // Format: v1$iv_b64$tag_b64$ciphertext_b64
79
+ return `v1$${iv.toString('base64')}$${tag.toString('base64')}$${encrypted.toString('base64')}`;
80
+ }
81
+
82
+ export function decryptSecret(stored) {
83
+ if (typeof stored !== 'string' || !stored.startsWith('v1$')) {
84
+ throw new Error('Invalid encrypted format');
85
+ }
86
+ const parts = stored.split('$');
87
+ if (parts.length !== 4) throw new Error('Invalid encrypted format');
88
+ const [_, ivB64, tagB64, ciphertextB64] = parts;
89
+ const key = _getMasterKey();
90
+ const iv = Buffer.from(ivB64, 'base64');
91
+ const tag = Buffer.from(tagB64, 'base64');
92
+ const ciphertext = Buffer.from(ciphertextB64, 'base64');
93
+ const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
94
+ decipher.setAuthTag(tag);
95
+ const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
96
+ return decrypted.toString('utf8');
97
+ }
98
+
99
+ // ─── EMAIL VALIDATION ────────────────────────────────────────────────────────
100
+ export function isValidEmail(email) {
101
+ if (typeof email !== 'string') return false;
102
+ // Pragmatic check, not RFC-perfect
103
+ return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email) && email.length <= 254;
104
+ }
105
+
106
+ // ─── ROLES ───────────────────────────────────────────────────────────────────
107
+ export const ROLES = {
108
+ ADMIN: 'admin',
109
+ EDITOR: 'editor',
110
+ VIEWER: 'viewer',
111
+ };
112
+
113
+ export function isValidRole(role) {
114
+ return Object.values(ROLES).includes(role);
115
+ }
116
+
117
+ // Whitelist of secret keys a user can store. Used to validate inputs to /api/user/secrets/:key.
118
+ // Anything not in this list is rejected (prevents arbitrary data being stored in user_secrets).
119
+ export const USER_SECRET_KEYS = [
120
+ // LLM providers
121
+ 'GEMINI_API_KEY',
122
+ 'CLAUDE_API_KEY',
123
+ 'OPENAI_API_KEY',
124
+ 'GOOGLE_API_KEY', // alias for Gemini in some configs
125
+ 'ANTHROPIC_API_KEY', // alias for Claude
126
+ // Issue trackers
127
+ 'GITHUB_TOKEN',
128
+ 'JIRA_EMAIL',
129
+ 'JIRA_API_TOKEN',
130
+ 'LINEAR_API_KEY',
131
+ ];
132
+
133
+ export function isValidSecretKey(key) {
134
+ return USER_SECRET_KEYS.includes(key);
135
+ }
136
+
137
+ // Permission helpers - centralised here so they're consistent across routes
138
+ export function canEdit(role) {
139
+ return role === ROLES.ADMIN || role === ROLES.EDITOR;
140
+ }
141
+
142
+ export function canManageUsers(role) {
143
+ return role === ROLES.ADMIN;
144
+ }
145
+
146
+ export function canRead(role) {
147
+ return [ROLES.ADMIN, ROLES.EDITOR, ROLES.VIEWER].includes(role);
148
+ }