feat: initial commit - Jhonny Editor
- Adicionado estrutura completa do projeto - Configurado MCP server para Premiere Pro - Adicionado documentação e skills - Configurado Gitignore para o projeto
This commit is contained in:
+402
@@ -0,0 +1,402 @@
|
||||
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import type http from "node:http";
|
||||
|
||||
export const MCP_HTTP_METHODS = ["GET", "POST", "DELETE"] as const;
|
||||
|
||||
export interface HttpAuthConfiguration {
|
||||
mode: "shared-token" | "oauth" | "unauthenticated";
|
||||
authToken?: string;
|
||||
oauth?: {
|
||||
issuer: string;
|
||||
audience: string;
|
||||
publicUrl: string;
|
||||
jwksUri: string;
|
||||
requiredScopes: string[];
|
||||
allowedSubjects: string[];
|
||||
};
|
||||
allowUnauthenticated: boolean;
|
||||
}
|
||||
|
||||
export interface HttpAdmissionSettings {
|
||||
maxRequestBytes: number;
|
||||
headersTimeoutMs: number;
|
||||
requestTimeoutMs: number;
|
||||
keepAliveTimeoutMs: number;
|
||||
maxRequestsPerSocket: number;
|
||||
maxConcurrentRequests: number;
|
||||
maxConcurrentStreams: number;
|
||||
rateLimitPerMinute: number;
|
||||
rateLimitBurst: number;
|
||||
maxRateLimitKeys: number;
|
||||
trustProxy: boolean;
|
||||
}
|
||||
|
||||
export interface AdmissionMetrics {
|
||||
activeRequests: number;
|
||||
activeOperationRequests: number;
|
||||
activeStreamRequests: number;
|
||||
trackedRateLimitKeys: number;
|
||||
}
|
||||
|
||||
export type AdmissionLane = "operation" | "stream";
|
||||
|
||||
export type AdmissionDecision =
|
||||
| { accepted: true; release: () => void }
|
||||
| { accepted: false; reason: "rate_limited" | "at_capacity"; statusCode: 429 | 503; retryAfterSeconds: number };
|
||||
|
||||
const ONE_MINUTE_MS = 60_000;
|
||||
const RATE_LIMIT_IDENTITY_KEY = randomBytes(32);
|
||||
|
||||
function readBoundedInteger(
|
||||
env: NodeJS.ProcessEnv,
|
||||
name: string,
|
||||
fallback: number,
|
||||
minimum: number,
|
||||
maximum: number,
|
||||
): number {
|
||||
const raw = env[name];
|
||||
if (raw === undefined || raw === "") return fallback;
|
||||
if (!/^\d+$/.test(raw)) {
|
||||
throw new Error(`${name} must be an integer between ${minimum} and ${maximum}`);
|
||||
}
|
||||
const value = Number(raw);
|
||||
if (!Number.isSafeInteger(value) || value < minimum || value > maximum) {
|
||||
throw new Error(`${name} must be an integer between ${minimum} and ${maximum}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the public-HTTP containment settings. Invalid values fail startup so a
|
||||
* typo cannot silently turn a request or socket bound into an unlimited one.
|
||||
*/
|
||||
export function readHttpAdmissionSettings(env: NodeJS.ProcessEnv): HttpAdmissionSettings {
|
||||
const rateLimitPerMinute = readBoundedInteger(env, "MCP_RATE_LIMIT_PER_MINUTE", 120, 1, 10_000);
|
||||
const rateLimitBurst = readBoundedInteger(env, "MCP_RATE_LIMIT_BURST", 30, 1, rateLimitPerMinute);
|
||||
|
||||
return {
|
||||
maxRequestBytes: readBoundedInteger(env, "MCP_MAX_REQUEST_BYTES", 1_048_576, 1_024, 10_485_760),
|
||||
headersTimeoutMs: readBoundedInteger(env, "MCP_HEADERS_TIMEOUT_MS", 10_000, 1_000, 60_000),
|
||||
requestTimeoutMs: readBoundedInteger(env, "MCP_REQUEST_TIMEOUT_MS", 60_000, 1_000, 300_000),
|
||||
keepAliveTimeoutMs: readBoundedInteger(env, "MCP_KEEP_ALIVE_TIMEOUT_MS", 5_000, 1_000, 60_000),
|
||||
maxRequestsPerSocket: readBoundedInteger(env, "MCP_MAX_REQUESTS_PER_SOCKET", 100, 1, 10_000),
|
||||
maxConcurrentRequests: readBoundedInteger(env, "MCP_MAX_CONCURRENT_REQUESTS", 8, 1, 128),
|
||||
maxConcurrentStreams: readBoundedInteger(env, "MCP_MAX_CONCURRENT_STREAMS", 32, 1, 1_024),
|
||||
rateLimitPerMinute,
|
||||
rateLimitBurst,
|
||||
maxRateLimitKeys: readBoundedInteger(env, "MCP_MAX_RATE_LIMIT_KEYS", 2_048, 16, 100_000),
|
||||
trustProxy: env.MCP_TRUST_PROXY === "1",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A network-reachable editor control plane must never start unauthenticated in
|
||||
* production. The override remains available only for local development and
|
||||
* test harnesses where it does not create a public deployment.
|
||||
*/
|
||||
export function readHttpAuthConfiguration(env: NodeJS.ProcessEnv): HttpAuthConfiguration {
|
||||
const authToken = env.MCP_AUTH_TOKEN?.trim();
|
||||
const oauthIssuer = env.MCP_OAUTH_ISSUER?.trim();
|
||||
const oauthAudience = env.MCP_OAUTH_AUDIENCE?.trim();
|
||||
const publicUrl = env.MCP_PUBLIC_URL?.trim();
|
||||
const oauthJwksUri = env.MCP_OAUTH_JWKS_URI?.trim();
|
||||
const oauthRequiredScopes = env.MCP_OAUTH_REQUIRED_SCOPES?.trim();
|
||||
const oauthAllowedSubjects = env.MCP_OAUTH_ALLOWED_SUBJECTS?.trim();
|
||||
const hasOAuthIntent = Boolean(
|
||||
oauthIssuer || oauthAudience || publicUrl || oauthJwksUri || oauthRequiredScopes || oauthAllowedSubjects,
|
||||
);
|
||||
|
||||
if (authToken && hasOAuthIntent) {
|
||||
throw new Error("Configure either MCP_AUTH_TOKEN or MCP_OAUTH_ISSUER, not both.");
|
||||
}
|
||||
|
||||
if (hasOAuthIntent) {
|
||||
if (!oauthIssuer || !oauthAudience || !publicUrl || !oauthJwksUri || !oauthAllowedSubjects) {
|
||||
throw new Error(
|
||||
"MCP_OAUTH_ISSUER, MCP_OAUTH_AUDIENCE, MCP_OAUTH_JWKS_URI, MCP_PUBLIC_URL, and " +
|
||||
"MCP_OAUTH_ALLOWED_SUBJECTS are all required for OAuth.",
|
||||
);
|
||||
}
|
||||
const issuer = parseSecureUrl(oauthIssuer, "MCP_OAUTH_ISSUER", env.NODE_ENV);
|
||||
const audience = parseSecureUrl(oauthAudience, "MCP_OAUTH_AUDIENCE", env.NODE_ENV);
|
||||
const canonicalPublicUrl = parseSecureUrl(publicUrl, "MCP_PUBLIC_URL", env.NODE_ENV);
|
||||
const jwksUri = parseSecureUrl(oauthJwksUri, "MCP_OAUTH_JWKS_URI", env.NODE_ENV);
|
||||
if (issuer.search) {
|
||||
throw new Error("MCP_OAUTH_ISSUER must not contain a query.");
|
||||
}
|
||||
if (canonicalPublicUrl.pathname !== "/" || canonicalPublicUrl.search || canonicalPublicUrl.hash) {
|
||||
throw new Error("MCP_PUBLIC_URL must be an origin without a path, query, or fragment.");
|
||||
}
|
||||
if (audience.href !== `${canonicalPublicUrl.origin}/mcp`) {
|
||||
throw new Error("MCP_OAUTH_AUDIENCE must exactly equal MCP_PUBLIC_URL plus /mcp.");
|
||||
}
|
||||
const requiredScopes = (oauthRequiredScopes ?? "premiere:mcp")
|
||||
.split(/[ ,]+/)
|
||||
.map((scope) => scope.trim())
|
||||
.filter(Boolean);
|
||||
if (requiredScopes.length === 0 || requiredScopes.some((scope) => !/^[\x21\x23-\x5B\x5D-\x7E]+$/.test(scope))) {
|
||||
throw new Error("MCP_OAUTH_REQUIRED_SCOPES must contain one or more valid OAuth scope values.");
|
||||
}
|
||||
const allowedSubjects = oauthAllowedSubjects
|
||||
.split(",")
|
||||
.map((subject) => subject.trim())
|
||||
.filter(Boolean);
|
||||
if (
|
||||
allowedSubjects.length === 0 ||
|
||||
allowedSubjects.some((subject) => subject.length > 255 || /[\u0000-\u001F\u007F]/.test(subject))
|
||||
) {
|
||||
throw new Error("MCP_OAUTH_ALLOWED_SUBJECTS must contain valid comma-separated token subjects.");
|
||||
}
|
||||
return {
|
||||
mode: "oauth",
|
||||
oauth: {
|
||||
issuer: issuer.pathname === "/" && !issuer.search ? issuer.origin : issuer.href,
|
||||
audience: audience.href,
|
||||
publicUrl: canonicalPublicUrl.origin,
|
||||
jwksUri: jwksUri.href,
|
||||
requiredScopes: [...new Set(requiredScopes)],
|
||||
allowedSubjects: [...new Set(allowedSubjects)],
|
||||
},
|
||||
allowUnauthenticated: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (authToken) return { mode: "shared-token", authToken, allowUnauthenticated: false };
|
||||
|
||||
if (env.ALLOW_UNAUTHENTICATED === "1" && env.NODE_ENV !== "production") {
|
||||
return { mode: "unauthenticated", allowUnauthenticated: true };
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
"MCP_AUTH_TOKEN is required for the HTTP transport. " +
|
||||
"ALLOW_UNAUTHENTICATED=1 is permitted only outside NODE_ENV=production.",
|
||||
);
|
||||
}
|
||||
|
||||
function parseSecureUrl(raw: string, name: string, nodeEnv: string | undefined): URL {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(raw);
|
||||
} catch {
|
||||
throw new Error(`${name} must be an absolute URL.`);
|
||||
}
|
||||
const localDevelopment = nodeEnv !== "production" &&
|
||||
parsed.protocol === "http:" &&
|
||||
(parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" || parsed.hostname === "[::1]");
|
||||
if (parsed.protocol !== "https:" && !localDevelopment) {
|
||||
throw new Error(`${name} must use HTTPS (HTTP is allowed only for loopback development).`);
|
||||
}
|
||||
if (parsed.username || parsed.password || parsed.hash) {
|
||||
throw new Error(`${name} must not contain credentials or a fragment.`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function getRequestPathname(rawUrl: string | undefined): string | undefined {
|
||||
if (!rawUrl) return undefined;
|
||||
try {
|
||||
return new URL(rawUrl, "http://localhost").pathname;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function isSupportedMcpMethod(method: string | undefined): boolean {
|
||||
return MCP_HTTP_METHODS.some((allowed) => allowed === method);
|
||||
}
|
||||
|
||||
export function requestContentLength(req: Pick<http.IncomingMessage, "headers">): number | undefined {
|
||||
const header = req.headers["content-length"];
|
||||
const value = Array.isArray(header) ? header[0] : header;
|
||||
if (value === undefined) return undefined;
|
||||
if (!/^\d+$/.test(value)) return Number.NaN;
|
||||
const parsed = Number(value);
|
||||
return Number.isSafeInteger(parsed) ? parsed : Number.NaN;
|
||||
}
|
||||
|
||||
export function exceedsRequestBodyLimit(
|
||||
req: Pick<http.IncomingMessage, "headers">,
|
||||
maxRequestBytes: number,
|
||||
): boolean {
|
||||
const contentLength = requestContentLength(req);
|
||||
return contentLength !== undefined && (!Number.isFinite(contentLength) || contentLength > maxRequestBytes);
|
||||
}
|
||||
|
||||
export class RequestBodyTooLargeError extends Error {
|
||||
constructor() {
|
||||
super("Request body too large");
|
||||
this.name = "RequestBodyTooLargeError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads an MCP request body with a hard byte cap before it reaches the transport.
|
||||
* This avoids attaching a second live data listener beside the transport, which
|
||||
* can otherwise race and consume a fast chunked body before the transport does.
|
||||
*/
|
||||
export function readBoundedRequestBody(req: http.IncomingMessage, maxRequestBytes: number): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let receivedBytes = 0;
|
||||
const cleanup = () => {
|
||||
req.off("data", onData);
|
||||
req.off("end", onEnd);
|
||||
req.off("error", onError);
|
||||
req.off("aborted", onAborted);
|
||||
};
|
||||
const onData = (chunk: unknown) => {
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
|
||||
receivedBytes += buffer.length;
|
||||
if (receivedBytes <= maxRequestBytes) {
|
||||
chunks.push(buffer);
|
||||
return;
|
||||
}
|
||||
cleanup();
|
||||
// Drain rather than destroy so the caller can reliably send its 413.
|
||||
req.resume();
|
||||
reject(new RequestBodyTooLargeError());
|
||||
};
|
||||
const onEnd = () => {
|
||||
cleanup();
|
||||
resolve(Buffer.concat(chunks));
|
||||
};
|
||||
const onError = (error: Error) => {
|
||||
cleanup();
|
||||
reject(error);
|
||||
};
|
||||
const onAborted = () => {
|
||||
cleanup();
|
||||
reject(new Error("Request aborted"));
|
||||
};
|
||||
|
||||
req.on("data", onData);
|
||||
req.once("end", onEnd);
|
||||
req.once("error", onError);
|
||||
req.once("aborted", onAborted);
|
||||
});
|
||||
}
|
||||
|
||||
export function isAuthorizedBearer(req: Pick<http.IncomingMessage, "headers">, authToken: string | undefined): boolean {
|
||||
if (!authToken) return true;
|
||||
const header = req.headers.authorization;
|
||||
const value = Array.isArray(header) ? header[0] : header ?? "";
|
||||
if (!value.startsWith("Bearer ")) return false;
|
||||
const provided = Buffer.from(value.slice(7));
|
||||
const expected = Buffer.from(authToken);
|
||||
if (provided.length !== expected.length) return false;
|
||||
return timingSafeBufferEqual(provided, expected);
|
||||
}
|
||||
|
||||
function timingSafeBufferEqual(left: Buffer, right: Buffer): boolean {
|
||||
return timingSafeEqual(left, right);
|
||||
}
|
||||
|
||||
function hashedIdentity(value: string): string {
|
||||
// A process-local keyed digest prevents network addresses from being
|
||||
// recovered through an offline dictionary attack if a bucket key
|
||||
// is ever observed. The key and derived identities are never persisted.
|
||||
return createHmac("sha256", RATE_LIMIT_IDENTITY_KEY).update(value).digest("hex").slice(0, 32);
|
||||
}
|
||||
|
||||
/**
|
||||
* The edge is authoritative by default. Honor X-Forwarded-For only after an
|
||||
* operator explicitly declares the proxy trusted; otherwise it is attacker
|
||||
* input and must not be used as a rate-limit identity.
|
||||
*/
|
||||
export function rateLimitIdentity(
|
||||
req: Pick<http.IncomingMessage, "headers" | "socket">,
|
||||
trustProxy: boolean,
|
||||
): string {
|
||||
const forwarded = req.headers["x-forwarded-for"];
|
||||
const forwardedValue = Array.isArray(forwarded) ? forwarded[0] : forwarded;
|
||||
const remoteAddress = trustProxy && forwardedValue
|
||||
? forwardedValue.split(",")[0].trim()
|
||||
: req.socket?.remoteAddress ?? "unknown";
|
||||
return `ip:${hashedIdentity(remoteAddress || "unknown")}`;
|
||||
}
|
||||
|
||||
interface TokenBucket {
|
||||
tokens: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bounded, process-local protection for a single machine. It deliberately does
|
||||
* not log or export identities. An edge/WAF remains necessary for fleet-wide
|
||||
* protection across restarts and multiple instances.
|
||||
*/
|
||||
export class HttpAdmissionController {
|
||||
private readonly buckets = new Map<string, TokenBucket>();
|
||||
private activeOperationRequests = 0;
|
||||
private activeStreamRequests = 0;
|
||||
|
||||
constructor(
|
||||
private readonly settings: Pick<HttpAdmissionSettings, "maxConcurrentRequests" | "maxConcurrentStreams" | "rateLimitPerMinute" | "rateLimitBurst" | "maxRateLimitKeys">,
|
||||
private readonly clock: () => number = Date.now,
|
||||
) {}
|
||||
|
||||
acquire(identity: string, lane: AdmissionLane = "operation"): AdmissionDecision {
|
||||
const now = this.clock();
|
||||
this.pruneIdleBuckets(now);
|
||||
|
||||
const bucket = this.getOrCreateBucket(identity, now);
|
||||
if (!bucket) {
|
||||
return { accepted: false, reason: "rate_limited", statusCode: 429, retryAfterSeconds: 60 };
|
||||
}
|
||||
|
||||
const elapsed = Math.max(0, now - bucket.updatedAt);
|
||||
const refill = elapsed * (this.settings.rateLimitPerMinute / ONE_MINUTE_MS);
|
||||
bucket.tokens = Math.min(this.settings.rateLimitBurst, bucket.tokens + refill);
|
||||
bucket.updatedAt = now;
|
||||
if (bucket.tokens < 1) {
|
||||
const missing = 1 - bucket.tokens;
|
||||
const retryAfterSeconds = Math.max(1, Math.ceil((missing / this.settings.rateLimitPerMinute) * 60));
|
||||
return { accepted: false, reason: "rate_limited", statusCode: 429, retryAfterSeconds };
|
||||
}
|
||||
|
||||
const activeRequests = lane === "stream" ? this.activeStreamRequests : this.activeOperationRequests;
|
||||
const maximumRequests = lane === "stream" ? this.settings.maxConcurrentStreams : this.settings.maxConcurrentRequests;
|
||||
if (activeRequests >= maximumRequests) {
|
||||
return { accepted: false, reason: "at_capacity", statusCode: 503, retryAfterSeconds: 1 };
|
||||
}
|
||||
|
||||
bucket.tokens -= 1;
|
||||
if (lane === "stream") this.activeStreamRequests += 1;
|
||||
else this.activeOperationRequests += 1;
|
||||
let released = false;
|
||||
return {
|
||||
accepted: true,
|
||||
release: () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
if (lane === "stream") this.activeStreamRequests = Math.max(0, this.activeStreamRequests - 1);
|
||||
else this.activeOperationRequests = Math.max(0, this.activeOperationRequests - 1);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
metrics(): AdmissionMetrics {
|
||||
return {
|
||||
activeRequests: this.activeOperationRequests + this.activeStreamRequests,
|
||||
activeOperationRequests: this.activeOperationRequests,
|
||||
activeStreamRequests: this.activeStreamRequests,
|
||||
trackedRateLimitKeys: this.buckets.size,
|
||||
};
|
||||
}
|
||||
|
||||
private getOrCreateBucket(identity: string, now: number): TokenBucket | undefined {
|
||||
const existing = this.buckets.get(identity);
|
||||
if (existing) return existing;
|
||||
if (this.buckets.size >= this.settings.maxRateLimitKeys) return undefined;
|
||||
const bucket = { tokens: this.settings.rateLimitBurst, updatedAt: now };
|
||||
this.buckets.set(identity, bucket);
|
||||
return bucket;
|
||||
}
|
||||
|
||||
private pruneIdleBuckets(now: number): void {
|
||||
const maxIdleMs = Math.max(ONE_MINUTE_MS, Math.ceil((this.settings.rateLimitBurst / this.settings.rateLimitPerMinute) * ONE_MINUTE_MS) * 2);
|
||||
for (const [identity, bucket] of this.buckets) {
|
||||
if (now - bucket.updatedAt > maxIdleMs) this.buckets.delete(identity);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user