@wolpertingerlabs/drawlatch 1.0.0-alpha.5.2 → 1.0.0-alpha.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -13,6 +13,8 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
13
13
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
14
14
  import { z } from 'zod';
15
15
  import crypto from 'node:crypto';
16
+ import fs from 'node:fs/promises';
17
+ import path from 'node:path';
16
18
  import { loadProxyConfig } from '../shared/config.js';
17
19
  import { loadKeyBundle, loadPublicKeys, EncryptedChannel } from '../shared/crypto/index.js';
18
20
  import { HandshakeInitiator, } from '../shared/protocol/index.js';
@@ -129,14 +131,58 @@ server.tool('secure_request', "Make an authenticated HTTP request through the en
129
131
  .optional()
130
132
  .describe('Request headers, may contain ${VAR} placeholders'),
131
133
  body: z.any().optional().describe('Request body (object for JSON, string for raw)'),
132
- }, async ({ method, url, headers, body }) => {
134
+ files: z
135
+ .array(z.object({
136
+ field: z
137
+ .string()
138
+ .describe('Form field name (e.g., "files[0]", "file", "attachment")'),
139
+ path: z.string().describe('Absolute path to the file on the local filesystem'),
140
+ filename: z.string().describe('Filename to use in the upload'),
141
+ contentType: z
142
+ .string()
143
+ .describe('MIME type (e.g., "image/png", "application/pdf")'),
144
+ }))
145
+ .optional()
146
+ .describe('File attachments for multipart/form-data uploads. When present, the request is sent as multipart with the JSON body as "payload_json". Use bodyFieldName to change the JSON part name.'),
147
+ bodyFieldName: z
148
+ .string()
149
+ .optional()
150
+ .describe('Form field name for the JSON body part in multipart requests (default: "payload_json"). Only used when files are present.'),
151
+ }, async ({ method, url, headers, body, files, bodyFieldName }) => {
133
152
  try {
134
- const result = await sendEncryptedRequest('http_request', {
153
+ const toolInput = {
135
154
  method,
136
155
  url,
137
156
  headers: headers ?? {},
138
157
  body,
139
- });
158
+ };
159
+ // Read and base64-encode files from the local filesystem before sending
160
+ // through the encrypted channel (remote server can't access local files)
161
+ if (files?.length) {
162
+ const MAX_FILE_SIZE = 25 * 1024 * 1024; // 25 MB per file
163
+ const encodedFiles = await Promise.all(files.map(async ({ field, path: filePath, filename, contentType }) => {
164
+ if (!path.isAbsolute(filePath)) {
165
+ throw new Error(`File path must be absolute: ${filePath}`);
166
+ }
167
+ let stat;
168
+ try {
169
+ stat = await fs.stat(filePath);
170
+ }
171
+ catch {
172
+ throw new Error(`File not found: ${filePath}`);
173
+ }
174
+ if (stat.size > MAX_FILE_SIZE) {
175
+ throw new Error(`File too large (${(stat.size / 1024 / 1024).toFixed(1)} MB): ${filePath} — max ${MAX_FILE_SIZE / 1024 / 1024} MB`);
176
+ }
177
+ const buffer = await fs.readFile(filePath);
178
+ return { field, data: buffer.toString('base64'), filename, contentType };
179
+ }));
180
+ toolInput.files = encodedFiles;
181
+ if (bodyFieldName) {
182
+ toolInput.bodyFieldName = bodyFieldName;
183
+ }
184
+ }
185
+ const result = await sendEncryptedRequest('http_request', toolInput);
140
186
  return {
141
187
  content: [
142
188
  {
@@ -27,6 +27,9 @@ export declare abstract class BaseIngestor extends EventEmitter {
27
27
  protected counter: number;
28
28
  protected lastEventAt: string | null;
29
29
  protected errorMessage?: string;
30
+ /** Per-instance epoch for event ID generation.
31
+ * Each instance claims a unique epoch so IDs never collide across restarts. */
32
+ private readonly bootEpoch;
30
33
  /** Recently seen idempotency keys for deduplication. */
31
34
  private readonly seenKeys;
32
35
  constructor(
@@ -18,15 +18,19 @@ const log = createLogger('ingestor');
18
18
  * When exceeded, the oldest half is pruned. */
19
19
  const MAX_SEEN_KEYS = 2000;
20
20
  /**
21
- * Epoch-based event IDs that are monotonically increasing across server reboots.
21
+ * Epoch-based event IDs that are monotonically increasing across restarts.
22
22
  * Format: `bootEpochSeconds * 1_000_000 + counter`.
23
23
  *
24
24
  * Uses seconds (not milliseconds) so the product stays within Number.MAX_SAFE_INTEGER.
25
25
  * This prevents clients with a stale `after_id` cursor from missing events
26
26
  * after a reboot — new IDs will always be higher than pre-reboot IDs
27
- * (assuming <1M events per boot and >1s between boots).
27
+ * (assuming <1M events per instance and >1s between restarts).
28
+ *
29
+ * Uses a monotonically increasing module-level epoch so that in-process restarts
30
+ * (e.g., LocalProxy.reinitialize()) also produce strictly increasing IDs.
31
+ * Each new BaseIngestor instance claims the next available epoch.
28
32
  */
29
- const BOOT_EPOCH = Math.floor(Date.now() / 1000);
33
+ let nextEpoch = Math.floor(Date.now() / 1000);
30
34
  const ID_MULTIPLIER = 1_000_000;
31
35
  export class BaseIngestor extends EventEmitter {
32
36
  connectionAlias;
@@ -38,6 +42,9 @@ export class BaseIngestor extends EventEmitter {
38
42
  counter = 0;
39
43
  lastEventAt = null;
40
44
  errorMessage;
45
+ /** Per-instance epoch for event ID generation.
46
+ * Each instance claims a unique epoch so IDs never collide across restarts. */
47
+ bootEpoch;
41
48
  /** Recently seen idempotency keys for deduplication. */
42
49
  seenKeys = new Set();
43
50
  constructor(
@@ -59,6 +66,9 @@ export class BaseIngestor extends EventEmitter {
59
66
  this.secrets = secrets;
60
67
  this.instanceId = instanceId;
61
68
  this.buffer = new RingBuffer(bufferSize);
69
+ // Claim a unique epoch for this instance — ensures event IDs are strictly
70
+ // increasing even when ingestors are restarted in-process.
71
+ this.bootEpoch = nextEpoch++;
62
72
  }
63
73
  /**
64
74
  * Push a new event into the ring buffer.
@@ -77,7 +87,7 @@ export class BaseIngestor extends EventEmitter {
77
87
  return;
78
88
  }
79
89
  const now = new Date();
80
- const id = BOOT_EPOCH * ID_MULTIPLIER + this.counter++;
90
+ const id = this.bootEpoch * ID_MULTIPLIER + this.counter++;
81
91
  const key = idempotencyKey ?? `${this.connectionAlias}:${crypto.randomUUID()}`;
82
92
  const event = {
83
93
  id,
@@ -60,8 +60,31 @@ export class DiscordGatewayIngestor extends BaseIngestor {
60
60
  this.state = 'stopped';
61
61
  this.clearAllTimers();
62
62
  if (this.ws) {
63
- this.ws.close(1000, 'Shutting down');
63
+ const ws = this.ws;
64
64
  this.ws = null;
65
+ // Wait for the WebSocket to fully close before resolving.
66
+ // This prevents a race where the old connection is still alive when a new
67
+ // ingestor starts. For Discord, this avoids the old gateway session
68
+ // lingering while a new IDENTIFY is sent (which Discord may reject
69
+ // if it sees two sessions for the same bot token).
70
+ return new Promise((resolve) => {
71
+ const onClose = () => {
72
+ ws.removeEventListener('close', onClose);
73
+ resolve();
74
+ };
75
+ if (ws.readyState === WebSocket.CLOSED) {
76
+ resolve();
77
+ }
78
+ else {
79
+ ws.addEventListener('close', onClose);
80
+ ws.close(1000, 'Shutting down');
81
+ // Safety timeout — don't block forever if close event never fires
82
+ setTimeout(() => {
83
+ ws.removeEventListener('close', onClose);
84
+ resolve();
85
+ }, 5000);
86
+ }
87
+ });
65
88
  }
66
89
  return Promise.resolve();
67
90
  }
@@ -47,8 +47,30 @@ export class SlackSocketModeIngestor extends BaseIngestor {
47
47
  this.state = 'stopped';
48
48
  this.clearReconnectTimer();
49
49
  if (this.ws) {
50
- this.ws.close(1000, 'Shutting down');
50
+ const ws = this.ws;
51
51
  this.ws = null;
52
+ // Wait for the WebSocket to fully close before resolving.
53
+ // This prevents a race where the old connection is still alive when a new
54
+ // ingestor starts, causing Slack to distribute events to the dying
55
+ // connection (where nobody processes them).
56
+ return new Promise((resolve) => {
57
+ const onClose = () => {
58
+ ws.removeEventListener('close', onClose);
59
+ resolve();
60
+ };
61
+ if (ws.readyState === WebSocket.CLOSED) {
62
+ resolve();
63
+ }
64
+ else {
65
+ ws.addEventListener('close', onClose);
66
+ ws.close(1000, 'Shutting down');
67
+ // Safety timeout — don't block forever if close event never fires
68
+ setTimeout(() => {
69
+ ws.removeEventListener('close', onClose);
70
+ resolve();
71
+ }, 5000);
72
+ }
73
+ });
52
74
  }
53
75
  return Promise.resolve();
54
76
  }
@@ -57,11 +57,26 @@ export declare function cleanupSessions(sessionsMap: Map<string, Pick<Session, '
57
57
  expiredSessions: string[];
58
58
  expiredHandshakes: string[];
59
59
  };
60
+ /** A file attachment transmitted as base64 data through the encrypted channel. */
61
+ export interface FileAttachment {
62
+ /** Form field name (e.g., "files[0]", "file", "attachment") */
63
+ field: string;
64
+ /** Base64-encoded file content */
65
+ data: string;
66
+ /** Filename for the upload */
67
+ filename: string;
68
+ /** MIME type (e.g., "image/png", "application/pdf") */
69
+ contentType: string;
70
+ }
60
71
  export interface ProxyRequestInput {
61
72
  method: string;
62
73
  url: string;
63
74
  headers?: Record<string, string>;
64
75
  body?: unknown;
76
+ /** File attachments — triggers multipart/form-data encoding */
77
+ files?: FileAttachment[];
78
+ /** Form field name for the JSON body part (default: "payload_json") */
79
+ bodyFieldName?: string;
65
80
  }
66
81
  export interface ProxyRequestResult {
67
82
  status: number;
@@ -145,7 +145,7 @@ setInterval(() => {
145
145
  * The only side effect is the outbound fetch().
146
146
  */
147
147
  export async function executeProxyRequest(input, routes) {
148
- const { method, url, headers = {}, body } = input;
148
+ const { method, url, headers = {}, body, files, bodyFieldName } = input;
149
149
  // Step 1: Find matching route — try raw URL first
150
150
  let matched = matchRoute(url, routes);
151
151
  let resolvedUrl = url;
@@ -190,17 +190,45 @@ export async function executeProxyRequest(input, routes) {
190
190
  // Only when the route explicitly opts in via resolveSecretsInBody — prevents
191
191
  // exfiltration of secrets by writing placeholder strings into API resources
192
192
  // and reading them back.
193
- let resolvedBody;
194
- if (typeof body === 'string') {
195
- resolvedBody = matched.resolveSecretsInBody ? resolvePlaceholders(body, matched.secrets) : body;
193
+ let fetchBody;
194
+ if (files?.length) {
195
+ // ── Multipart mode: build FormData with file attachments ──
196
+ const form = new FormData();
197
+ // Add the JSON body as a named part (default: "payload_json" for Discord-style APIs)
198
+ if (body !== null && body !== undefined) {
199
+ const serialized = typeof body === 'string' ? body : JSON.stringify(body);
200
+ const resolvedPayload = matched.resolveSecretsInBody
201
+ ? resolvePlaceholders(serialized, matched.secrets)
202
+ : serialized;
203
+ form.append(bodyFieldName ?? 'payload_json', resolvedPayload);
204
+ }
205
+ // Attach each file from base64 data
206
+ for (const file of files) {
207
+ const buffer = Buffer.from(file.data, 'base64');
208
+ const blob = new Blob([buffer], { type: file.contentType });
209
+ form.append(file.field, blob, file.filename);
210
+ }
211
+ fetchBody = form;
212
+ // Let fetch auto-set Content-Type with the correct multipart boundary —
213
+ // remove any Content-Type that may have been set by route headers
214
+ delete resolvedHeaders['Content-Type'];
215
+ delete resolvedHeaders['content-type'];
196
216
  }
197
- else if (body !== null && body !== undefined) {
198
- const serialized = JSON.stringify(body);
199
- resolvedBody = matched.resolveSecretsInBody
200
- ? resolvePlaceholders(serialized, matched.secrets)
201
- : serialized;
202
- if (!resolvedHeaders['content-type'] && !resolvedHeaders['Content-Type']) {
203
- resolvedHeaders['Content-Type'] = 'application/json';
217
+ else {
218
+ // ── Standard JSON/string body ──
219
+ if (typeof body === 'string') {
220
+ fetchBody = matched.resolveSecretsInBody
221
+ ? resolvePlaceholders(body, matched.secrets)
222
+ : body;
223
+ }
224
+ else if (body !== null && body !== undefined) {
225
+ const serialized = JSON.stringify(body);
226
+ fetchBody = matched.resolveSecretsInBody
227
+ ? resolvePlaceholders(serialized, matched.secrets)
228
+ : serialized;
229
+ if (!resolvedHeaders['content-type'] && !resolvedHeaders['Content-Type']) {
230
+ resolvedHeaders['Content-Type'] = 'application/json';
231
+ }
204
232
  }
205
233
  }
206
234
  // Step 6: Final endpoint check on fully resolved URL
@@ -211,7 +239,7 @@ export async function executeProxyRequest(input, routes) {
211
239
  const resp = await fetch(resolvedUrl, {
212
240
  method,
213
241
  headers: resolvedHeaders,
214
- body: resolvedBody,
242
+ body: fetchBody,
215
243
  });
216
244
  const contentType = resp.headers.get('content-type') ?? '';
217
245
  let responseBody;
@@ -833,8 +861,8 @@ export function createApp(options = {}) {
833
861
  const app = express();
834
862
  // Parse JSON for handshake endpoints
835
863
  app.use('/handshake', express.json());
836
- // Raw buffer for encrypted request endpoint
837
- app.use('/request', express.raw({ type: 'application/octet-stream', limit: '10mb' }));
864
+ // Raw buffer for encrypted request endpoint (50 MB to accommodate base64-encoded file uploads)
865
+ app.use('/request', express.raw({ type: 'application/octet-stream', limit: '50mb' }));
838
866
  // Raw buffer for webhook endpoints (needed for signature verification)
839
867
  app.use('/webhooks', express.raw({ type: 'application/json', limit: '1mb' }));
840
868
  const config = options.config ?? loadRemoteConfig();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wolpertingerlabs/drawlatch",
3
- "version": "1.0.0-alpha.5.2",
3
+ "version": "1.0.0-alpha.7.0",
4
4
  "description": "Encrypted MCP proxy with mutual authentication. Local MCP server forwards requests through an encrypted channel to a remote secrets-holding server.",
5
5
  "type": "module",
6
6
  "main": "./dist/mcp/server.js",
@@ -55,8 +55,8 @@
55
55
  "scripts": {
56
56
  "build": "tsc && rm -rf dist/connections && cp -r src/connections dist/connections",
57
57
  "prepare": "npm run build",
58
- "dev:remote": "tsx src/remote/server.ts",
59
- "dev:mcp": "tsx src/mcp/server.ts",
58
+ "dev:remote": "MCP_CONFIG_DIR=~/.drawlatch-dev tsx src/remote/server.ts",
59
+ "dev:mcp": "MCP_CONFIG_DIR=~/.drawlatch-dev tsx src/mcp/server.ts",
60
60
  "generate-keys": "tsx src/cli/generate-keys.ts",
61
61
  "start:remote": "NODE_ENV=production node dist/remote/server.js",
62
62
  "start:mcp": "node dist/mcp/server.js",