@sellable/install
Version:
One-command installer for Sellable MCP in Claude Code, Codex, and Hermes
346 lines (339 loc) • 11.5 kB
JavaScript
// The machine-local Slack Socket Mode stand-in for a shared-app runtime.
import { timingSafeEqual } from "node:crypto";
import { once } from "node:events";
import { createServer } from "node:http";
// The shared app declares /sellable; Hermes registers /hermes. Every other
// command reaches Hermes under its own name.
export const SHARED_SLACK_COMMAND_ALIASES = Object.freeze({
"/sellable": "/hermes",
});
const record = (value) =>
value !== null && typeof value === "object" && !Array.isArray(value);
const malformed = () => new Error("slack_envelope_malformed");
// One stored Slack inbox row becomes one Socket Mode frame
// (slack_sdk/socket_mode/request.py). Events keep Slack's exact body bytes;
// interactive and slash bodies are Slack's form, decoded as Bolt decodes it.
export function toSocketModeFrame({
id,
kind,
body,
slackRetryNum,
slackRetryReason,
}) {
if (
typeof id !== "string" ||
!id ||
typeof body !== "string" ||
!Number.isInteger(slackRetryNum) ||
slackRetryNum < 0 ||
typeof slackRetryReason !== "string"
)
throw malformed();
if (kind === "EVENTS_API") {
let parsed;
try {
parsed = JSON.parse(body);
} catch {
throw malformed();
}
if (!record(parsed)) throw malformed();
return (
`{"envelope_id":${JSON.stringify(id)},"type":"events_api",` +
`"accepts_response_payload":false,"retry_attempt":${slackRetryNum},` +
`"retry_reason":${JSON.stringify(slackRetryReason)},"payload":${body}}`
);
}
const frame = (type, payload) =>
JSON.stringify({
envelope_id: id,
type,
accepts_response_payload: true,
retry_attempt: slackRetryNum,
retry_reason: slackRetryReason,
payload,
});
if (kind === "INTERACTIVE") {
let payload;
try {
payload = JSON.parse(new URLSearchParams(body).get("payload"));
} catch {
throw malformed();
}
if (!record(payload)) throw malformed();
return frame("interactive", payload);
}
if (kind === "SLASH_COMMANDS") {
const fields = Object.fromEntries(new URLSearchParams(body));
if (typeof fields.command !== "string" || !fields.command)
throw malformed();
fields.command =
SHARED_SLACK_COMMAND_ALIASES[fields.command] ?? fields.command;
return frame("slash_commands", fields);
}
throw malformed();
}
const json = (res, status, body) => {
res.writeHead(status, { "content-type": "application/json" });
res.end(JSON.stringify(body));
};
// Length-checked first: timingSafeEqual throws on unequal lengths.
const same = (provided, expected) => {
const a = Buffer.from(provided ?? "");
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
};
const OPEN = 1; // WebSocket.OPEN
// Node's parser admits request targets URL rejects (for example "http://[").
// A throw in either handler would take the customer runtime down.
const targetOf = (raw) => {
try {
return new URL(raw, "http://127.0.0.1");
} catch {
return null;
}
};
const API_METHOD = /^\/api\/([A-Za-z0-9_.]+)$/;
const HOP_BY_HOP = new Set([
"host",
"content-length",
"connection",
"transfer-encoding",
"keep-alive",
"upgrade",
]);
const SLACK_CHANNEL_ID = /^[CDG][A-Z0-9]+$/;
// slack_sdk sends some arguments in the query string, and the status call
// names the channel channel_id.
const channelOf = (body, contentType, searchParams) => {
let fields = {};
try {
fields = /json/.test(contentType ?? "")
? JSON.parse(body)
: Object.fromEntries(new URLSearchParams(body));
} catch {}
const channel =
fields?.channel ??
fields?.channel_id ??
searchParams.get("channel") ??
searchParams.get("channel_id");
return typeof channel === "string" && SLACK_CHANNEL_ID.test(channel)
? channel
: null;
};
// Hermes connects here with the per-boot local app token as if this were
// Slack (docs.slack.dev/apis/events-api/using-socket-mode). The token and
// ticket never appear in a diagnostic.
export async function startSlackStandIn(options = {}) {
const {
localAppToken,
ticket,
appId,
upstreamOrigin,
fetchImpl = fetch,
ackTimeoutMs = 10_000,
upstreamTimeoutMs = 30_000,
warmGraceMs = 8_000,
now = Date.now,
diagnostic = () => {},
} = options;
if (
typeof localAppToken !== "string" ||
!localAppToken ||
typeof ticket !== "string" ||
!ticket ||
typeof appId !== "string" ||
typeof upstreamOrigin !== "string" ||
typeof fetchImpl !== "function" ||
typeof now !== "function" ||
typeof diagnostic !== "function"
)
throw new Error("slack_stand_in_configuration_rejected");
// Imported here so the Admin image, which loads customer-runtime.mjs but
// never starts the stand-in, does not need ws.
const { WebSocketServer } = await import("ws");
const wss = new WebSocketServer({ noServer: true });
let current = null;
let connectedAt = 0;
let port = 0;
// One in-flight envelope per deliver() call (the relay is sequential). Each
// waiter knows the socket it was sent on, so a replaced socket releases its
// own envelope for redelivery instead of letting it time out as no_ack.
const waiters = new Map();
const server = createServer(async (req, res) => {
const target = targetOf(req.url);
if (target?.pathname === "/api/apps.connections.open") {
const ok = same(req.headers.authorization, `Bearer ${localAppToken}`);
diagnostic({
kind: "sellable_slack_stand_in_api",
method: "apps.connections.open",
status: 200,
ok,
error: ok ? null : "invalid_auth",
channel: null,
});
// The SDK reconnects with its cached URL, so the URL is reusable.
return json(
res,
200,
ok
? { ok, url: `ws://127.0.0.1:${port}/link/?ticket=${ticket}` }
: { ok, error: "invalid_auth" }
);
}
const method = target && API_METHOD.exec(target.pathname)?.[1];
if (!method) return json(res, 404, { ok: false, error: "unknown_method" });
if (req.method !== "GET" && req.method !== "POST")
return json(res, 405, { ok: false, error: "method_not_allowed" });
// Every other call goes to Slack with Hermes' own token, and Slack's
// answer comes back as is. If Slack resets or hangs, Hermes' connection
// is dropped so its client retries as it would against Slack. This
// handler never rejects: the runtime has no unhandledRejection handler.
const line = {
kind: "sellable_slack_stand_in_api",
method,
status: null,
ok: null,
error: null,
channel: null,
};
try {
const parts = [];
for await (const part of req) parts.push(part);
const body = Buffer.concat(parts);
line.channel = channelOf(
String(body),
req.headers["content-type"],
target.searchParams
);
const headers = {};
for (const [name, value] of Object.entries(req.headers))
if (!HOP_BY_HOP.has(name)) headers[name] = value;
const upstream = await fetchImpl(
`${upstreamOrigin}/api/${method}${target.search}`,
{
method: req.method,
headers,
body: req.method === "POST" ? body : undefined,
signal: AbortSignal.timeout(upstreamTimeoutMs),
}
);
const answer = Buffer.from(await upstream.arrayBuffer());
line.status = upstream.status;
try {
const parsed = JSON.parse(String(answer));
if (typeof parsed?.ok === "boolean") line.ok = parsed.ok;
if (typeof parsed?.error === "string") line.error = parsed.error;
} catch {}
// fetch has already decoded the body, so content-encoding is not
// copied; only the headers Hermes' client reads come back.
const out = {};
for (const name of ["content-type", "retry-after"]) {
const value = upstream.headers.get(name);
if (value !== null) out[name] = value;
}
res.writeHead(upstream.status, out);
res.end(answer);
} catch (error) {
line.error =
error?.name === "TimeoutError"
? "upstream_timeout"
: "upstream_unavailable";
req.socket.destroy();
}
diagnostic(line);
});
server.on("upgrade", (req, socket, head) => {
socket.on("error", () => socket.destroy());
const target = targetOf(req.url);
if (
target?.pathname !== "/link/" ||
!same(target.searchParams.get("ticket"), ticket)
) {
socket.once("finish", () => socket.destroy());
socket.end("HTTP/1.1 401 Unauthorized\r\nContent-Length: 0\r\n\r\n");
return;
}
wss.handleUpgrade(req, socket, head, (ws) => {
// Newest connection wins; Hermes closes its old socket first anyway.
current?.close(1000);
current = ws;
connectedAt = now();
diagnostic({ kind: "sellable_slack_stand_in_socket", event: "open" });
ws.on("error", () => {});
// Acks resolve their waiter; ack payloads are dropped (the inbox already
// answered Slack). Nothing here may throw.
ws.on("message", (data, isBinary) => {
if (isBinary) return;
let frame;
try {
frame = JSON.parse(String(data));
} catch {
return;
}
waiters.get(frame?.envelope_id)?.settle("delivered");
});
ws.on("close", () => {
if (current === ws) current = null;
for (const waiter of [...waiters.values()])
if (waiter.ws === ws) waiter.settle("released");
diagnostic({ kind: "sellable_slack_stand_in_socket", event: "close" });
});
ws.send(
JSON.stringify({
type: "hello",
num_connections: 1,
debug_info: { host: "sellable-local-stand-in" },
connection_info: { app_id: appId },
})
);
});
});
server.listen(0, "127.0.0.1");
await once(server, "listening");
port = server.address().port;
return {
origin: `http://127.0.0.1:${port}`,
port,
// delivered on Hermes' ack, no_ack past the deadline (Bolt never acks an
// envelope no listener matches), released when no socket takes it.
async deliver(envelope) {
const frame = toSocketModeFrame(envelope);
const ws = current;
const outcome = await new Promise((resolve) => {
if (ws?.readyState !== OPEN) return resolve("released");
const waiter = {
ws,
settle(result) {
clearTimeout(timer);
if (waiters.get(envelope.id) === waiter)
waiters.delete(envelope.id);
resolve(result);
},
};
const timer = setTimeout(() => waiter.settle("no_ack"), ackTimeoutMs);
waiters.set(envelope.id, waiter);
ws.send(frame);
});
if (outcome !== "delivered")
diagnostic({
kind: "sellable_slack_stand_in_delivery",
envelopeId: envelope.id,
outcome,
});
return outcome;
},
status() {
const hermesConnected = current?.readyState === OPEN;
return {
hermesConnected,
warm: hermesConnected && now() - connectedAt >= warmGraceMs,
};
},
close: () =>
new Promise((resolve) => {
for (const ws of wss.clients) ws.terminate();
server.close(resolve);
server.closeAllConnections();
}),
};
}