diff --git a/docs/CONTRIBUTING_SECURITY.md b/docs/CONTRIBUTING_SECURITY.md new file mode 100644 index 0000000..dbfba01 --- /dev/null +++ b/docs/CONTRIBUTING_SECURITY.md @@ -0,0 +1,51 @@ +# Security Checklist for Contributors — Closes #50 + +Review before pushing code. Supplements [CONTRIBUTING.md](./CONTRIBUTING.md). + +## Before Opening a Pull Request + +### Secrets & Credentials +- [ ] No API keys, tokens, or passwords in source code +- [ ] Environment variables used for all secrets (see `.env.example`) +- [ ] `.env` and `.env.local` are in `.gitignore` +- [ ] No hardcoded JWT secrets or signing keys + +### Dependencies +- [ ] Run `npm audit` — no critical or high vulnerabilities +- [ ] New dependencies reviewed for maintenance status +- [ ] Lockfile (`package-lock.json`) updated +- [ ] No deprecated packages introduced + +### Input Validation +- [ ] All user inputs validated server-side +- [ ] SQL/NoSQL injection protections in place +- [ ] File upload paths sanitized and size-limited +- [ ] XSS protections: output encoding, CSP headers + +### Authentication & Authorization +- [ ] Endpoints gated with auth middleware +- [ ] Role-based access control enforced +- [ ] Session tokens use secure, httpOnly, SameSite flags +- [ ] Rate limiting on auth endpoints + +### Data Protection +- [ ] Sensitive data encrypted at rest +- [ ] PII minimized +- [ ] Logging does not capture passwords, tokens, or PII +- [ ] CORS configured with specific origins (not `*`) + +### API Security +- [ ] API responses don't leak stack traces +- [ ] Pagination limits on list endpoints +- [ ] Content-Type headers validated +- [ ] HTTPS enforced (HSTS header) + +### Testing +- [ ] Security-focused test cases included +- [ ] Edge cases tested (empty input, max-length, special chars) +- [ ] Error paths tested + +## CI/CD Pipeline +- [ ] `npm test` passes locally and in CI +- [ ] `npm run lint` passes with no errors +- [ ] Git hooks (husky) pass pre-commit checks diff --git a/docs/REPOSITORY_MAP.md b/docs/REPOSITORY_MAP.md new file mode 100644 index 0000000..6b53936 --- /dev/null +++ b/docs/REPOSITORY_MAP.md @@ -0,0 +1,77 @@ +# Stellarmind Repository Map — Closes #48 + +A guided tour for new contributors. + +## Top-Level Layout + +``` +stellarmind/ +├── .github/ # CI workflows, issue templates +├── .husky/ # Git hooks (pre-commit checks) +├── docs/ # Project documentation +├── public/ # Static assets and entry HTML +│ ├── assets/ # CSS, JS, images +│ │ ├── css/ # Stylesheets +│ │ └── js/ # Client-side JavaScript +│ └── index.html # Main entry point +├── src/ # Application source code +│ ├── agents/ # AI agent orchestration modules +│ ├── api/ # REST API route handlers +│ ├── middleware/ # Express middleware (auth, validation) +│ ├── pricing/ # Pricing engine and calculators +│ ├── orchestrator/ # Multi-agent coordination logic +│ └── utils/ # Shared utilities and helpers +├── tests/ # Test suites +│ └── load/ # Load test scenarios (k6, Artillery) +├── package.json # Node.js dependencies and scripts +├── vercel.json # Vercel deployment configuration +├── CONTRIBUTING.md # Contribution guidelines +├── SECURITY.md # Security policy +└── README.md # Project overview +``` + +## Key Directories + +### `src/agents/` +Each agent module handles a specific AI task. Agents communicate through the +orchestrator. See `src/orchestrator/` for the coordination layer. + +### `src/api/` +Express route handlers. New endpoints should be registered here and follow +REST conventions. Each route file exports a router. + +### `src/pricing/` +The pricing engine that calculates costs based on agent usage, token +consumption, and plan tiers. See `PRICING_INDEX.md` for business logic. + +### `tests/` +- `*.test.js` — Unit and integration tests (Jest) +- `load/` — Performance and load tests (k6, Artillery) + +## Quick Start (New Contributors) + +1. **Read**: `CONTRIBUTING.md` and this repository map +2. **Setup**: `nvm use && npm install` +3. **Develop**: `npm run dev` starts the development server +4. **Test**: `npm test` runs the full test suite +5. **Lint**: `npm run lint` checks code style +6. **Submit**: Open a PR following the PR template + +## Common Workflows + +| Task | Command | +|------|---------| +| Start dev server | `npm run dev` | +| Run all tests | `npm test` | +| Run single test | `npx jest path/to/test` | +| Lint code | `npm run lint` | +| Build for production | `npm run build` | + +## Architecture Overview + +Stellarmind uses an **orchestrator pattern** where a central coordination +module dispatches tasks to specialized AI agents. Agents are stateless; +state is managed at the orchestrator level and persisted via the API layer. + +For detailed architecture, see `FRONTEND_STRUCTURE.md` and the inline +documentation in `src/orchestrator/`. diff --git a/public/assets/css/accessibility.css b/public/assets/css/accessibility.css new file mode 100644 index 0000000..9c7ceb0 --- /dev/null +++ b/public/assets/css/accessibility.css @@ -0,0 +1,61 @@ +/* Accessibility Improvements — Closes #35 */ +.skip-link { + position: absolute; + top: -40px; + left: 6px; + background: var(--bg-primary, #fff); + color: var(--text-primary, #000); + padding: 8px 16px; + z-index: 10000; + border-radius: 0 0 4px 4px; + text-decoration: none; + font-weight: 600; + transition: top 0.2s ease; +} +.skip-link:focus { + top: 0; +} +:focus-visible { + outline: 3px solid var(--focus-color, #4A90D9); + outline-offset: 2px; + border-radius: 2px; +} +:focus:not(:focus-visible) { + outline: none; +} +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border-width: 0; +} +@media (prefers-contrast: high) { + :root { + --border-color: #000; + --text-primary: #000; + --bg-primary: #fff; + } + button, .btn, input, select, textarea { + border: 2px solid #000; + } +} +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} +button, .btn, [role="button"], a.nav-link, .clickable { + min-height: 44px; + min-width: 44px; +} +.text-muted { + color: #666 !important; +} diff --git a/public/assets/css/responsive.css b/public/assets/css/responsive.css new file mode 100644 index 0000000..2f65e40 --- /dev/null +++ b/public/assets/css/responsive.css @@ -0,0 +1,86 @@ +/* Mobile Responsiveness Improvements — Closes #34 */ +/* Sidebar collapse on tablets */ +@media (max-width: 1024px) { + .sidebar { + width: 60px; + overflow: hidden; + } + .sidebar:hover, + .sidebar:focus-within { + width: 240px; + } + .sidebar .nav-label { + display: none; + } + .sidebar:hover .nav-label, + .sidebar:focus-within .nav-label { + display: inline; + } + .main-content { + margin-left: 60px; + } +} + +/* Full mobile collapse */ +@media (max-width: 768px) { + .sidebar { + transform: translateX(-100%); + position: fixed; + z-index: 1000; + height: 100vh; + transition: transform 0.3s ease; + } + .sidebar.open { + transform: translateX(0); + width: 240px; + } + .sidebar.open .nav-label { + display: inline; + } + .main-content { + margin-left: 0; + padding: 1rem; + } + .mobile-menu-toggle { + display: block; + } +} + +/* Table responsiveness */ +@media (max-width: 768px) { + table { + display: block; + overflow-x: auto; + white-space: nowrap; + } + .dashboard-grid { + grid-template-columns: 1fr; + } + .card { + min-width: 0; + } +} + +/* Print styles */ +@media print { + .sidebar, .mobile-menu-toggle, .no-print { + display: none !important; + } + .main-content { + margin-left: 0 !important; + } +} + +/* Mobile menu toggle */ +.mobile-menu-toggle { + display: none; + position: fixed; + top: 1rem; + left: 1rem; + z-index: 1001; + background: var(--bg-primary, #fff); + border: 1px solid var(--border-color, #ddd); + border-radius: 4px; + padding: 0.5rem; + cursor: pointer; +} diff --git a/public/assets/js/accessibility.js b/public/assets/js/accessibility.js new file mode 100644 index 0000000..bfb9d06 --- /dev/null +++ b/public/assets/js/accessibility.js @@ -0,0 +1,70 @@ +// Accessibility enhancements — Closes #35 +(function () { + 'use strict'; + + function injectSkipLink() { + var skipLink = document.createElement('a'); + skipLink.href = '#main-content'; + skipLink.className = 'skip-link'; + skipLink.textContent = 'Skip to main content'; + document.body.prepend(skipLink); + } + + function injectLiveRegion() { + var region = document.createElement('div'); + region.id = 'aria-live-region'; + region.className = 'sr-only'; + region.setAttribute('aria-live', 'polite'); + region.setAttribute('aria-atomic', 'true'); + document.body.appendChild(region); + } + + function setupFocusTraps() { + var observer = new MutationObserver(function (mutations) { + mutations.forEach(function (mutation) { + mutation.addedNodes.forEach(function (node) { + if (node.nodeType !== 1) return; + var dialog = node.matches && node.matches('[role="dialog"], dialog') + ? node + : node.querySelector && node.querySelector('[role="dialog"], dialog'); + if (!dialog) return; + + var focusable = dialog.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])' + ); + if (focusable.length === 0) return; + + var first = focusable[0]; + var last = focusable[focusable.length - 1]; + + dialog.addEventListener('keydown', function (e) { + if (e.key !== 'Tab') return; + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); + last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); + first.focus(); + } + }); + + setTimeout(function () { first.focus(); }, 100); + }); + }); + }); + + observer.observe(document.body, { childList: true, subtree: true }); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } + + function init() { + injectSkipLink(); + injectLiveRegion(); + setupFocusTraps(); + } +})(); diff --git a/tests/load/load-test.js b/tests/load/load-test.js new file mode 100644 index 0000000..185b3ab --- /dev/null +++ b/tests/load/load-test.js @@ -0,0 +1,91 @@ +// Load test scenarios for Stellarmind — Closes #38 +// Run: npx k6 run tests/load/load-test.js + +import http from 'k6/http'; +import { check, sleep, group } from 'k6'; +import { Rate, Trend } from 'k6/metrics'; + +const errorRate = new Rate('errors'); +const sseConnectTime = new Trend('sse_connect_time'); +const orchestratorTime = new Trend('orchestrator_response_time'); + +const BASE_URL = __ENV.BASE_URL || 'http://localhost:3000'; +const MAX_VUS = __ENV.MAX_VUS ? parseInt(__ENV.MAX_VUS) : 50; + +export const options = { + stages: [ + { duration: '10s', target: 10 }, + { duration: '20s', target: 25 }, + { duration: '20s', target: MAX_VUS }, + { duration: '30s', target: 0 }, + ], + thresholds: { + http_req_duration: ['p(95)<2000'], + errors: ['rate<0.10'], + }, +}; + +const BASELINE = { + apiResponseMs: 150, + sseReconnectMs: 500, + orchestratorP95Ms: 800, +}; + +export default function () { + group('GET /api/health', function () { + const res = http.get(BASE_URL + '/api/health'); + check(res, { + 'health status 200': (r) => r.status === 200, + 'health response time OK': (r) => r.timings.duration < 1000, + }); + errorRate.add(res.status !== 200); + }); + + group('GET /api/pricing', function () { + const res = http.get(BASE_URL + '/api/pricing'); + check(res, { + 'pricing status 2xx': (r) => r.status >= 200 && r.status < 500, + 'pricing response under 2s': (r) => r.timings.duration < 2000, + }); + orchestratorTime.add(res.timings.duration); + errorRate.add(res.status >= 500); + }); + + group('GET /api/orchestrator/status', function () { + const res = http.get(BASE_URL + '/api/orchestrator/status'); + check(res, { + 'orchestrator status 2xx': (r) => r.status >= 200 && r.status < 500, + 'orchestrator under 3s': (r) => r.timings.duration < 3000, + }); + orchestratorTime.add(res.timings.duration); + }); + + group('GET / (SSE simulation)', function () { + const res = http.get(BASE_URL + '/'); + check(res, { + 'homepage loads': (r) => r.status === 200, + 'homepage under 2s': (r) => r.timings.duration < 2000, + }); + sseConnectTime.add(res.timings.duration); + }); + + sleep(1); +} + +export function handleSummary(data) { + const summary = { + timestamp: new Date().toISOString(), + baseline: BASELINE, + results: { + totalRequests: (data.metrics.http_reqs || {}).values ? data.metrics.http_reqs.values.count : 0, + errorRate: (data.metrics.errors || {}).values ? data.metrics.errors.values.rate : 0, + p95ResponseTime: data.metrics.http_req_duration ? (data.metrics.http_req_duration.values['p(95)'] || 0) : 0, + }, + assessment: 'BASELINE ESTABLISHED', + }; + + return { + 'stdout': JSON.stringify(summary, null, 2), + 'tests/load/results.json': JSON.stringify(summary, null, 2), + }; +} diff --git a/tests/load/load-test.yml b/tests/load/load-test.yml new file mode 100644 index 0000000..479cfeb --- /dev/null +++ b/tests/load/load-test.yml @@ -0,0 +1,57 @@ +# Artillery load test config for Stellarmind — Closes #38 +config: + target: "{{ $processEnvironment.BASE_URL || 'http://localhost:3000' }}" + phases: + - duration: 60 + arrivalRate: 5 + rampTo: 20 + name: "Warm up" + - duration: 120 + arrivalRate: 20 + rampTo: 50 + name: "Ramp up load" + - duration: 60 + arrivalRate: 50 + name: "Sustained load" + ensure: + thresholds: + - http.response_time.p99: 2000 + - http.response_time.p95: 1000 + conditions: + - expression: "errors.rate < 0.05" + +scenarios: + - name: "Health check" + flow: + - get: + url: "/api/health" + expect: + - statusCode: 200 + + - name: "Pricing endpoint" + flow: + - get: + url: "/api/pricing" + expect: + - statusCode: + - 200 + - 404 + + - name: "Orchestrator status" + flow: + - get: + url: "/api/orchestrator/status" + expect: + - statusCode: + - 200 + - 404 + + - name: "SSE simulation (homepage)" + flow: + - get: + url: "/" + expect: + - statusCode: 200 + - think: 1 + - get: + url: "/api/health"