@pimzino/claude-code-spec-workflow
Version:
Automated workflows for Claude Code. Includes spec-driven development (Requirements → Design → Tasks → Implementation) with intelligent task execution, optional steering documents and streamlined bug fix workflow (Report → Analyze → Fix → Verify). We have
234 lines • 7.32 kB
JavaScript
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.AccessController = void 0;
const crypto_1 = __importDefault(require("crypto"));
class AccessController {
constructor(_options = {
readOnlyMode: false,
rateLimitAttempts: 5,
rateLimitWindow: 60000
}) {
this._options = _options;
this.passwords = new Map();
this.rateLimits = new Map();
this.readOnlySessions = new Set();
this.sessions = new Map();
this.MAX_ATTEMPTS = 5;
this.RATE_LIMIT_WINDOW = 60000; // 1 minute
this.SESSION_DURATION = 3600000; // 1 hour
/**
* Middleware to enforce read-only access
*/
this.enforceReadOnly = async (req, reply) => {
// Skip if not in read-only mode
if (!this._options.readOnlyMode) {
return;
}
// Allow GET requests and WebSocket connections
if (req.method === 'GET' || req.url === '/ws') {
return;
}
// Block all other methods
reply.code(403).send({
error: 'Read-only access',
message: 'This dashboard is in read-only mode. Only viewing is allowed.'
});
};
}
/**
* Set password for a tunnel
*/
setPassword(tunnelId, password) {
const hash = this.hashPassword(password);
this.passwords.set(tunnelId, {
hash,
attempts: 0,
lastAttempt: 0
});
}
/**
* Validate password for a tunnel
*/
validatePassword(tunnelId, password, clientIp) {
const entry = this.passwords.get(tunnelId);
if (!entry)
return true; // No password set
// Check rate limit
if (clientIp && !this.checkRateLimit(clientIp)) {
throw new Error('Too many attempts. Please try again later.');
}
const hash = this.hashPassword(password);
const isValid = hash === entry.hash;
if (!isValid) {
entry.attempts++;
entry.lastAttempt = Date.now();
if (clientIp) {
this.incrementRateLimit(clientIp);
}
}
return isValid;
}
/**
* Create a read-only WebSocket wrapper
*/
wrapWebSocketForReadOnly(socket, sessionId) {
if (!this._options.readOnlyMode)
return;
// Mark session as read-only
this.readOnlySessions.add(sessionId);
// Wrap the send method to add readOnly flag to messages
const originalSend = socket.send.bind(socket);
socket.send = (data) => {
try {
const message = typeof data === 'string' ? JSON.parse(data) : data;
message.readOnly = true;
originalSend(JSON.stringify(message));
}
catch {
// If we can't parse it, send as-is
originalSend(data);
}
};
console.log(`WebSocket ${sessionId} marked as read-only`);
// Clean up on close
socket.on('close', () => {
this.readOnlySessions.delete(sessionId);
});
}
/**
* Check if a session is read-only
*/
isReadOnlySession(sessionId) {
return this.readOnlySessions.has(sessionId);
}
/**
* Filter WebSocket messages for read-only mode
*/
filterWebSocketMessage(data) {
try {
const str = data.toString();
const message = JSON.parse(str);
// Filter out any interactive or write-related messages
if (message.type === 'command' ||
message.type === 'action' ||
message.type === 'write') {
return null;
}
// Add read-only indicator to messages
if (message.type === 'initial' || message.type === 'update') {
message.readOnly = true;
}
return JSON.stringify(message);
}
catch {
// If we can't parse it, don't send it in read-only mode
return null;
}
}
/**
* Hash password using SHA-256
*/
hashPassword(password) {
return crypto_1.default.createHash('sha256').update(password).digest('hex');
}
/**
* Check rate limit for an IP
*/
checkRateLimit(ip) {
const entry = this.rateLimits.get(ip);
const now = Date.now();
if (!entry || now > entry.resetAt) {
return true;
}
return entry.count < (this._options.rateLimitAttempts || this.MAX_ATTEMPTS);
}
/**
* Increment rate limit counter
*/
incrementRateLimit(ip) {
const now = Date.now();
const entry = this.rateLimits.get(ip);
if (!entry || now > entry.resetAt) {
this.rateLimits.set(ip, {
count: 1,
resetAt: now + (this._options.rateLimitWindow || this.RATE_LIMIT_WINDOW)
});
}
else {
entry.count++;
}
}
/**
* Clean up expired rate limit entries
*/
cleanupRateLimits() {
const now = Date.now();
for (const [ip, entry] of this.rateLimits.entries()) {
if (now > entry.resetAt) {
this.rateLimits.delete(ip);
}
}
}
/**
* Create a new session after successful authentication
*/
createSession(tunnelId) {
const sessionToken = `session-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const now = Date.now();
this.sessions.set(sessionToken, {
tunnelId,
createdAt: now,
expiresAt: now + this.SESSION_DURATION
});
// Clean up expired sessions periodically
this.cleanupSessions();
return sessionToken;
}
/**
* Validate a session token
*/
validateSession(sessionToken, tunnelId) {
const session = this.sessions.get(sessionToken);
if (!session) {
return false;
}
const now = Date.now();
// Check if session is expired
if (now > session.expiresAt) {
this.sessions.delete(sessionToken);
return false;
}
// Check if session is for the correct tunnel
if (session.tunnelId !== tunnelId) {
return false;
}
return true;
}
/**
* Clean up expired sessions
*/
cleanupSessions() {
const now = Date.now();
for (const [token, session] of this.sessions.entries()) {
if (now > session.expiresAt) {
this.sessions.delete(token);
}
}
}
/**
* Get access statistics
*/
getStats() {
return {
activeSessions: this.readOnlySessions.size,
authenticatedSessions: this.sessions.size,
protectedTunnels: this.passwords.size,
rateLimitedIps: this.rateLimits.size
};
}
}
exports.AccessController = AccessController;
//# sourceMappingURL=access-controller.js.map