@stackmemoryai/stackmemory
Version:
Project-scoped memory for AI coding tools. Durable context across sessions with MCP integration, frames, smart retrieval, Claude Code skills, and automatic hooks.
556 lines (555 loc) • 17.4 kB
JavaScript
import { fileURLToPath as __fileURLToPath } from 'url';
import { dirname as __pathDirname } from 'path';
const __filename = __fileURLToPath(import.meta.url);
const __dirname = __pathDirname(__filename);
import { createServer } from "http";
import { parse as parseUrl } from "url";
import { existsSync, readFileSync } from "fs";
import { join } from "path";
import { homedir } from "os";
import { createHmac } from "crypto";
import { execFileSync } from "child_process";
import {
processIncomingResponse,
loadSMSConfig,
cleanupExpiredPrompts,
sendNotification
} from "./sms-notify.js";
import {
queueAction,
executeActionSafe,
cleanupOldActions
} from "./sms-action-runner.js";
import {
isCommand,
processCommand,
sendCommandResponse
} from "./whatsapp-commands.js";
import { writeFileSecure, ensureSecureDir } from "./secure-fs.js";
import {
logWebhookRequest,
logRateLimit,
logSignatureInvalid,
logBodyTooLarge,
logContentTypeInvalid,
logActionAllowed,
logActionBlocked,
logCleanup
} from "./security-logger.js";
const CLEANUP_INTERVAL_MS = 5 * 60 * 1e3;
const MAX_SMS_BODY_LENGTH = 1e3;
const MAX_PHONE_LENGTH = 50;
const MAX_BODY_SIZE = 50 * 1024;
const RATE_LIMIT_WINDOW_MS = 60 * 1e3;
const RATE_LIMIT_MAX_REQUESTS = 30;
const ACTION_TIMEOUT_MS = 6e4;
async function executeActionWithTimeout(action, response) {
return Promise.race([
executeActionSafe(action, response),
new Promise(
(_, reject) => setTimeout(
() => reject(new Error(`Action timed out after ${ACTION_TIMEOUT_MS}ms`)),
ACTION_TIMEOUT_MS
)
)
]).catch((error) => ({
success: false,
error: error instanceof Error ? error.message : String(error)
}));
}
const RATE_LIMIT_PATH = join(homedir(), ".stackmemory", "rate-limits.json");
let rateLimitCache = {};
let rateLimitCacheDirty = false;
function loadRateLimits() {
try {
if (existsSync(RATE_LIMIT_PATH)) {
const data = JSON.parse(readFileSync(RATE_LIMIT_PATH, "utf8"));
const now = Date.now();
const cleaned = {};
for (const [ip, record] of Object.entries(data)) {
const r = record;
if (r.resetTime > now) {
cleaned[ip] = r;
}
}
return cleaned;
}
} catch {
}
return {};
}
function saveRateLimits() {
if (!rateLimitCacheDirty) return;
try {
ensureSecureDir(join(homedir(), ".stackmemory"));
writeFileSecure(RATE_LIMIT_PATH, JSON.stringify(rateLimitCache));
rateLimitCacheDirty = false;
} catch {
}
}
setInterval(saveRateLimits, 3e4);
rateLimitCache = loadRateLimits();
function checkRateLimit(ip) {
const now = Date.now();
const record = rateLimitCache[ip];
if (!record || now > record.resetTime) {
rateLimitCache[ip] = { count: 1, resetTime: now + RATE_LIMIT_WINDOW_MS };
rateLimitCacheDirty = true;
return true;
}
if (record.count >= RATE_LIMIT_MAX_REQUESTS) {
return false;
}
record.count++;
rateLimitCacheDirty = true;
return true;
}
function verifyTwilioSignature(url, params, signature) {
const authToken = process.env["TWILIO_AUTH_TOKEN"];
if (!authToken) {
const isDev = process.env["NODE_ENV"] === "development" || process.env["SKIP_TWILIO_VERIFICATION"] === "true";
if (isDev) {
console.warn(
"[sms-webhook] TWILIO_AUTH_TOKEN not set, skipping verification (dev mode)"
);
return true;
}
console.error(
"[sms-webhook] TWILIO_AUTH_TOKEN not set - rejecting request in production"
);
return false;
}
const sortedKeys = Object.keys(params).sort();
let data = url;
for (const key of sortedKeys) {
data += key + params[key];
}
const hmac = createHmac("sha1", authToken);
hmac.update(data);
const expectedSignature = hmac.digest("base64");
return signature === expectedSignature;
}
function parseFormData(body) {
const params = new URLSearchParams(body);
const result = {};
params.forEach((value, key) => {
result[key] = value;
});
return result;
}
function storeLatestResponse(promptId, response, action) {
ensureSecureDir(join(homedir(), ".stackmemory"));
const responsePath = join(
homedir(),
".stackmemory",
"sms-latest-response.json"
);
writeFileSecure(
responsePath,
JSON.stringify({
promptId,
response,
action,
timestamp: (/* @__PURE__ */ new Date()).toISOString()
})
);
}
function storeIncomingRequest(from, message) {
ensureSecureDir(join(homedir(), ".stackmemory"));
const requestPath = join(
homedir(),
".stackmemory",
"sms-incoming-request.json"
);
writeFileSecure(
requestPath,
JSON.stringify({
from,
message,
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
processed: false
})
);
}
function getIncomingRequest() {
const requestPath = join(
homedir(),
".stackmemory",
"sms-incoming-request.json"
);
if (!existsSync(requestPath)) {
return null;
}
try {
const data = JSON.parse(readFileSync(requestPath, "utf-8"));
if (data.processed) {
return null;
}
return data;
} catch {
return null;
}
}
function markRequestProcessed() {
const requestPath = join(
homedir(),
".stackmemory",
"sms-incoming-request.json"
);
if (!existsSync(requestPath)) {
return;
}
try {
const data = JSON.parse(readFileSync(requestPath, "utf-8"));
data.processed = true;
writeFileSecure(requestPath, JSON.stringify(data));
} catch {
}
}
async function handleSMSWebhook(payload) {
const { From, Body } = payload;
if (Body && Body.length > MAX_SMS_BODY_LENGTH) {
console.log(`[sms-webhook] Body too long: ${Body.length} chars`);
return { response: "Message too long. Max 1000 characters." };
}
if (From && From.length > MAX_PHONE_LENGTH) {
console.log(
`[sms-webhook] Phone number too long: ${From.length} chars (max ${MAX_PHONE_LENGTH}): ${From.substring(0, 30)}...`
);
return { response: "Invalid phone number." };
}
console.log(`[sms-webhook] Received from ${From}: ${Body}`);
if (isCommand(Body)) {
console.log(`[sms-webhook] Processing command: ${Body}`);
const cmdResult = await processCommand(From, Body);
if (cmdResult.handled) {
if (cmdResult.response) {
sendCommandResponse(cmdResult.response).catch(console.error);
}
return {
response: cmdResult.response || "Command processed",
action: cmdResult.action,
queued: false
};
}
}
const result = processIncomingResponse(From, Body);
if (!result.matched) {
if (result.prompt) {
return {
response: `Invalid response. Expected: ${result.prompt.options.map((o) => o.key).join(", ")}`
};
}
storeIncomingRequest(From, Body);
console.log(
`[sms-webhook] Stored new request from ${From}: ${Body.substring(0, 50)}...`
);
return { response: "Got it! Your request has been queued." };
}
storeLatestResponse(
result.prompt?.id || "unknown",
result.response || Body,
result.action
);
triggerResponseNotification(result.response || Body);
if (result.action) {
console.log(`[sms-webhook] Executing action: ${result.action}`);
const actionResult = await executeActionWithTimeout(
result.action,
result.response || Body
);
if (actionResult.success) {
logActionAllowed("sms-webhook", result.action);
console.log(
`[sms-webhook] Action completed: ${(actionResult.output || "").substring(0, 200)}`
);
return {
response: `Done! Action executed successfully.`,
action: result.action,
queued: false
};
} else {
logActionBlocked(
"sms-webhook",
result.action,
actionResult.error || "unknown"
);
console.log(`[sms-webhook] Action failed: ${actionResult.error}`);
queueAction(
result.prompt?.id || "unknown",
result.response || Body,
result.action
);
return {
response: `Action failed, queued for retry: ${(actionResult.error || "").substring(0, 50)}`,
action: result.action,
queued: true
};
}
}
return {
response: `Received: ${result.response}. Next action will be triggered.`
};
}
function escapeAppleScript(str) {
return str.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r").substring(0, 200);
}
function triggerResponseNotification(response) {
const safeMessage = escapeAppleScript(`SMS Response: ${response}`);
try {
execFileSync(
"osascript",
[
"-e",
`display notification "${safeMessage}" with title "StackMemory" sound name "Glass"`
],
{ stdio: "ignore", timeout: 5e3 }
);
} catch {
}
try {
const signalPath = join(homedir(), ".stackmemory", "sms-signal.txt");
writeFileSecure(
signalPath,
JSON.stringify({
type: "sms_response",
response,
timestamp: (/* @__PURE__ */ new Date()).toISOString()
})
);
} catch {
}
console.log(`
*** SMS RESPONSE RECEIVED: "${response}" ***`);
console.log(`*** Run: stackmemory notify run-actions ***
`);
}
function twimlResponse(message) {
return `<?xml version="1.0" encoding="UTF-8"?>
<Response>
<Message>${escapeXml(message)}</Message>
</Response>`;
}
function escapeXml(str) {
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
}
function startWebhookServer(port = 3456) {
const server = createServer(
async (req, res) => {
const url = parseUrl(req.url || "/", true);
if (url.pathname === "/health") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "ok" }));
return;
}
if ((url.pathname === "/sms" || url.pathname === "/sms/incoming" || url.pathname === "/webhook") && req.method === "POST") {
const clientIp = req.socket.remoteAddress || "unknown";
logWebhookRequest(
"sms-webhook",
req.method || "POST",
url.pathname || "/sms",
clientIp
);
if (!checkRateLimit(clientIp)) {
logRateLimit("sms-webhook", clientIp);
res.writeHead(429, {
"Content-Type": "text/xml",
"Retry-After": "60"
});
res.end(twimlResponse("Too many requests. Please try again later."));
return;
}
const contentType = req.headers["content-type"] || "";
if (!contentType.includes("application/x-www-form-urlencoded")) {
logContentTypeInvalid("sms-webhook", contentType, clientIp);
res.writeHead(400, { "Content-Type": "text/xml" });
res.end(twimlResponse("Invalid content type"));
return;
}
let body = "";
let bodyTooLarge = false;
req.on("data", (chunk) => {
body += chunk;
if (body.length > MAX_BODY_SIZE) {
bodyTooLarge = true;
logBodyTooLarge("sms-webhook", body.length, clientIp);
req.destroy();
}
});
req.on("end", async () => {
if (bodyTooLarge) {
res.writeHead(413, { "Content-Type": "text/xml" });
res.end(twimlResponse("Request too large"));
return;
}
try {
const payload = parseFormData(
body
);
const twilioSignature = req.headers["x-twilio-signature"];
const webhookUrl = `${req.headers["x-forwarded-proto"] || "http"}://${req.headers.host}${req.url}`;
if (twilioSignature && !verifyTwilioSignature(
webhookUrl,
payload,
twilioSignature
)) {
logSignatureInvalid("sms-webhook", clientIp);
console.error("[sms-webhook] Invalid Twilio signature");
res.writeHead(401, { "Content-Type": "text/xml" });
res.end(twimlResponse("Unauthorized"));
return;
}
const result = await handleSMSWebhook(payload);
res.writeHead(200, { "Content-Type": "text/xml" });
res.end(twimlResponse(result.response));
} catch (err) {
console.error("[sms-webhook] Error:", err);
res.writeHead(500, { "Content-Type": "text/xml" });
res.end(twimlResponse("Error processing message"));
}
});
return;
}
if (url.pathname === "/sms/status" && req.method === "POST") {
let body = "";
req.on("data", (chunk) => {
body += chunk;
});
req.on("end", () => {
try {
const payload = parseFormData(body);
console.log(
`[sms-webhook] Status update: ${payload["MessageSid"]} -> ${payload["MessageStatus"]}`
);
const statusPath = join(
homedir(),
".stackmemory",
"sms-status.json"
);
const statuses = existsSync(statusPath) ? JSON.parse(readFileSync(statusPath, "utf8")) : {};
statuses[payload["MessageSid"]] = payload["MessageStatus"];
writeFileSecure(statusPath, JSON.stringify(statuses, null, 2));
res.writeHead(200, { "Content-Type": "text/plain" });
res.end("OK");
} catch (err) {
console.error("[sms-webhook] Status error:", err);
res.writeHead(500);
res.end("Error");
}
});
return;
}
if (url.pathname === "/status") {
const config = loadSMSConfig();
res.writeHead(200, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
enabled: config.enabled,
pendingPrompts: config.pendingPrompts.length
})
);
return;
}
if (url.pathname === "/request" && req.method === "GET") {
const request = getIncomingRequest();
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ request }));
return;
}
if (url.pathname === "/request/ack" && req.method === "POST") {
markRequestProcessed();
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ success: true }));
return;
}
if (url.pathname === "/send" && req.method === "POST") {
let body = "";
req.on("data", (chunk) => {
body += chunk;
if (body.length > MAX_BODY_SIZE) {
req.destroy();
}
});
req.on("end", async () => {
try {
const payload = JSON.parse(body);
const message = payload.message || payload.body || "";
const title = payload.title || "Notification";
const type = payload.type || "custom";
if (!message) {
res.writeHead(400, { "Content-Type": "application/json" });
res.end(
JSON.stringify({ success: false, error: "Message required" })
);
return;
}
const result = await sendNotification({
type,
title,
message
});
res.writeHead(result.success ? 200 : 500, {
"Content-Type": "application/json"
});
res.end(JSON.stringify(result));
} catch (err) {
console.error("[sms-webhook] Send error:", err);
res.writeHead(500, { "Content-Type": "application/json" });
res.end(
JSON.stringify({
success: false,
error: err instanceof Error ? err.message : "Send failed"
})
);
}
});
return;
}
res.writeHead(404);
res.end("Not found");
}
);
server.listen(port, () => {
console.log(`[sms-webhook] Server listening on port ${port}`);
console.log(
`[sms-webhook] Incoming messages: http://localhost:${port}/sms/incoming`
);
console.log(
`[sms-webhook] Status callback: http://localhost:${port}/sms/status`
);
console.log(`[sms-webhook] Configure these URLs in Twilio console`);
setInterval(() => {
try {
const expiredPrompts = cleanupExpiredPrompts();
const oldActions = cleanupOldActions();
if (expiredPrompts > 0 || oldActions > 0) {
logCleanup("sms-webhook", expiredPrompts, oldActions);
console.log(
`[sms-webhook] Cleanup: ${expiredPrompts} expired prompts, ${oldActions} old actions`
);
}
} catch {
}
}, CLEANUP_INTERVAL_MS);
console.log(
`[sms-webhook] Cleanup interval: every ${CLEANUP_INTERVAL_MS / 1e3}s`
);
});
}
async function smsWebhookMiddleware(req, res) {
const result = await handleSMSWebhook(req.body);
res.type("text/xml");
res.send(twimlResponse(result.response));
}
if (process.argv[1]?.endsWith("sms-webhook.js")) {
const port = parseInt(process.env["SMS_WEBHOOK_PORT"] || "3456", 10);
startWebhookServer(port);
}
export {
getIncomingRequest,
handleSMSWebhook,
markRequestProcessed,
smsWebhookMiddleware,
startWebhookServer
};
//# sourceMappingURL=sms-webhook.js.map