@wolpertingerlabs/drawlatch 1.0.0-alpha.2 → 1.0.0-alpha.29

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.
Files changed (119) hide show
  1. package/CONNECTIONS.md +3 -0
  2. package/README.md +395 -465
  3. package/bin/drawlatch.js +1018 -49
  4. package/dist/auth/auth.d.ts +10 -0
  5. package/dist/auth/auth.js +146 -0
  6. package/dist/auth/env-writer.d.ts +6 -0
  7. package/dist/auth/env-writer.js +52 -0
  8. package/dist/auth/password.d.ts +8 -0
  9. package/dist/auth/password.js +28 -0
  10. package/dist/auth/sessions.d.ts +13 -0
  11. package/dist/auth/sessions.js +85 -0
  12. package/dist/cli/generate-keys.d.ts +4 -4
  13. package/dist/cli/generate-keys.js +30 -25
  14. package/dist/connections/{anthropic.json → ai/anthropic.json} +19 -1
  15. package/dist/connections/{devin.json → ai/devin.json} +7 -1
  16. package/dist/connections/{google-ai.json → ai/google-ai.json} +7 -1
  17. package/dist/connections/{openai.json → ai/openai.json} +7 -1
  18. package/dist/connections/{openrouter.json → ai/openrouter.json} +7 -1
  19. package/dist/connections/developer-tools/github.json +138 -0
  20. package/dist/connections/{hex.json → developer-tools/hex.json} +7 -1
  21. package/dist/connections/developer-tools/linear.json +75 -0
  22. package/dist/connections/{lichess.json → gaming/lichess.json} +7 -1
  23. package/dist/connections/messaging/agentmail.json +21 -0
  24. package/dist/connections/messaging/discord-bot.json +114 -0
  25. package/dist/connections/{discord-oauth.json → messaging/discord-oauth.json} +7 -1
  26. package/dist/connections/messaging/slack.json +75 -0
  27. package/dist/connections/messaging/telegram.json +66 -0
  28. package/dist/connections/{google.json → productivity/google.json} +7 -1
  29. package/dist/connections/productivity/notion.json +75 -0
  30. package/dist/connections/productivity/stripe.json +74 -0
  31. package/dist/connections/productivity/trello.json +113 -0
  32. package/dist/connections/{bluesky.json → social-media/bluesky.json} +41 -0
  33. package/dist/connections/social-media/mastodon.json +65 -0
  34. package/dist/connections/social-media/reddit.json +80 -0
  35. package/dist/connections/{twitch.json → social-media/twitch.json} +40 -0
  36. package/dist/connections/social-media/x.json +67 -0
  37. package/dist/mcp/server.js +544 -31
  38. package/dist/remote/admin-mutations.d.ts +37 -0
  39. package/dist/remote/admin-mutations.js +251 -0
  40. package/dist/remote/admin-types.d.ts +149 -0
  41. package/dist/remote/admin-types.js +11 -0
  42. package/dist/remote/admin.d.ts +37 -0
  43. package/dist/remote/admin.js +316 -0
  44. package/dist/remote/caller-bootstrap.d.ts +71 -0
  45. package/dist/remote/caller-bootstrap.js +141 -0
  46. package/dist/remote/ingestors/base-ingestor.d.ts +19 -3
  47. package/dist/remote/ingestors/base-ingestor.js +27 -6
  48. package/dist/remote/ingestors/discord/discord-gateway.d.ts +2 -2
  49. package/dist/remote/ingestors/discord/discord-gateway.js +29 -6
  50. package/dist/remote/ingestors/e2e/setup.d.ts +69 -0
  51. package/dist/remote/ingestors/e2e/setup.js +147 -0
  52. package/dist/remote/ingestors/manager.d.ts +110 -10
  53. package/dist/remote/ingestors/manager.js +449 -42
  54. package/dist/remote/ingestors/poll/poll-ingestor.d.ts +4 -2
  55. package/dist/remote/ingestors/poll/poll-ingestor.js +26 -5
  56. package/dist/remote/ingestors/registry.d.ts +2 -2
  57. package/dist/remote/ingestors/registry.js +2 -2
  58. package/dist/remote/ingestors/slack/socket-mode.d.ts +2 -2
  59. package/dist/remote/ingestors/slack/socket-mode.js +28 -6
  60. package/dist/remote/ingestors/types.d.ts +25 -0
  61. package/dist/remote/ingestors/webhook/base-webhook-ingestor.d.ts +46 -7
  62. package/dist/remote/ingestors/webhook/base-webhook-ingestor.js +115 -10
  63. package/dist/remote/ingestors/webhook/github-webhook-ingestor.d.ts +30 -0
  64. package/dist/remote/ingestors/webhook/github-webhook-ingestor.js +73 -2
  65. package/dist/remote/ingestors/webhook/lifecycle-types.d.ts +63 -0
  66. package/dist/remote/ingestors/webhook/lifecycle-types.js +12 -0
  67. package/dist/remote/ingestors/webhook/stripe-webhook-ingestor.js +2 -2
  68. package/dist/remote/ingestors/webhook/trello-webhook-ingestor.d.ts +17 -5
  69. package/dist/remote/ingestors/webhook/trello-webhook-ingestor.js +32 -26
  70. package/dist/remote/ingestors/webhook/webhook-lifecycle-manager.d.ts +46 -0
  71. package/dist/remote/ingestors/webhook/webhook-lifecycle-manager.js +261 -0
  72. package/dist/remote/server.d.ts +25 -33
  73. package/dist/remote/server.js +681 -195
  74. package/dist/remote/tool-dispatch.d.ts +84 -0
  75. package/dist/remote/tool-dispatch.js +910 -0
  76. package/dist/remote/triggers/rule-engine.d.ts +40 -0
  77. package/dist/remote/triggers/rule-engine.js +228 -0
  78. package/dist/remote/triggers/types.d.ts +69 -0
  79. package/dist/remote/triggers/types.js +10 -0
  80. package/dist/remote/tunnel-state.d.ts +14 -0
  81. package/dist/remote/tunnel-state.js +20 -0
  82. package/dist/remote/tunnel.d.ts +53 -0
  83. package/dist/remote/tunnel.js +149 -0
  84. package/dist/shared/config.d.ts +97 -25
  85. package/dist/shared/config.js +48 -35
  86. package/dist/shared/connections.d.ts +21 -5
  87. package/dist/shared/connections.js +56 -14
  88. package/dist/shared/crypto/index.d.ts +1 -0
  89. package/dist/shared/crypto/index.js +1 -0
  90. package/dist/shared/crypto/key-manager.d.ts +67 -0
  91. package/dist/shared/crypto/key-manager.js +152 -0
  92. package/dist/shared/env-utils.d.ts +42 -0
  93. package/dist/shared/env-utils.js +150 -0
  94. package/dist/shared/listener-config.d.ts +157 -0
  95. package/dist/shared/listener-config.js +10 -0
  96. package/dist/shared/migrations.d.ts +40 -0
  97. package/dist/shared/migrations.js +122 -0
  98. package/dist/shared/protocol/handshake.js +14 -1
  99. package/dist/shared/protocol/index.d.ts +2 -0
  100. package/dist/shared/protocol/index.js +2 -0
  101. package/dist/shared/protocol/sync-client.d.ts +52 -0
  102. package/dist/shared/protocol/sync-client.js +99 -0
  103. package/dist/shared/protocol/sync.d.ts +71 -0
  104. package/dist/shared/protocol/sync.js +176 -0
  105. package/frontend/dist/assets/index-D4k5QKBP.css +1 -0
  106. package/frontend/dist/assets/index-IzwnSOmu.js +267 -0
  107. package/frontend/dist/index.html +15 -0
  108. package/package.json +66 -13
  109. package/dist/connections/discord-bot.json +0 -24
  110. package/dist/connections/github.json +0 -25
  111. package/dist/connections/linear.json +0 -29
  112. package/dist/connections/mastodon.json +0 -25
  113. package/dist/connections/notion.json +0 -33
  114. package/dist/connections/reddit.json +0 -28
  115. package/dist/connections/slack.json +0 -23
  116. package/dist/connections/stripe.json +0 -25
  117. package/dist/connections/telegram.json +0 -26
  118. package/dist/connections/trello.json +0 -25
  119. package/dist/connections/x.json +0 -27
@@ -12,23 +12,38 @@
12
12
  * - Maintains an audit log of all operations
13
13
  * - Rate-limits requests per session
14
14
  */
15
+ import cookieParser from 'cookie-parser';
15
16
  import dotenv from 'dotenv';
16
17
  import express from 'express';
18
+ import rateLimit from 'express-rate-limit';
17
19
  import fs from 'node:fs';
18
- import { loadRemoteConfig, resolveRoutes, resolveCallerRoutes, resolveSecrets, resolvePlaceholders, getEnvFilePath, } from '../shared/config.js';
20
+ import path from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+ import { loadRemoteConfig, saveRemoteConfig, resolveRoutes, resolveCallerRoutes, resolveSecrets, getEnvFilePath, getRemoteConfigPath, } from '../shared/config.js';
19
23
  import { loadKeyBundle, loadPublicKeys, EncryptedChannel, } from '../shared/crypto/index.js';
20
24
  import { HandshakeResponder, } from '../shared/protocol/index.js';
25
+ import { decryptSyncPayload, encryptSyncPayload, validateSyncRequest, isSyncSessionActive, MAX_SYNC_ATTEMPTS, } from '../shared/protocol/sync.js';
26
+ import { importCallerPublicKeys, exportServerPublicKeys, callerFingerprint, } from '../shared/crypto/key-manager.js';
27
+ import { getCallerKeysDir, getServerKeysDir } from '../shared/config.js';
21
28
  import { IngestorManager } from './ingestors/index.js';
29
+ import { listConnectionTemplates } from '../shared/connections.js';
30
+ import { isSecretSetForCaller } from '../shared/env-utils.js';
31
+ import { toolHandlers } from './tool-dispatch.js';
32
+ import { setTunnelUrl, getTunnelUrl } from './tunnel-state.js';
33
+ import { migrateConfigDir } from '../shared/migrations.js';
34
+ import { writeEnrollToken, autoEnroll } from './caller-bootstrap.js';
35
+ import { createAdminRouter } from './admin.js';
36
+ import { loginHandler, logoutHandler, checkAuthHandler, changePasswordHandler, requireAuth, } from '../auth/auth.js';
22
37
  // ── Environment loading ─────────────────────────────────────────────────────
23
38
  /** Load environment from ~/.drawlatch/.env, falling back to cwd .env (legacy). */
24
39
  function loadEnvFile() {
25
40
  const configDirEnvPath = getEnvFilePath();
26
41
  if (fs.existsSync(configDirEnvPath)) {
27
- dotenv.config({ path: configDirEnvPath });
42
+ dotenv.config({ path: configDirEnvPath, quiet: true });
28
43
  return;
29
44
  }
30
45
  // Backward compat: fall back to cwd .env
31
- const result = dotenv.config();
46
+ const result = dotenv.config({ quiet: true });
32
47
  if (result.parsed) {
33
48
  console.warn(`[remote] Loaded .env from working directory. ` +
34
49
  `Move it to ${configDirEnvPath} for portable operation.`);
@@ -39,26 +54,68 @@ loadEnvFile();
39
54
  const sessions = new Map();
40
55
  const pendingHandshakes = new Map();
41
56
  let rateLimitPerMinute = 60;
57
+ /** Read package.json version once at module load. */
58
+ const PKG_VERSION = (() => {
59
+ try {
60
+ const raw = fs.readFileSync(new URL('../../package.json', import.meta.url), 'utf8');
61
+ return JSON.parse(raw).version ?? 'unknown';
62
+ }
63
+ catch {
64
+ return 'unknown';
65
+ }
66
+ })();
67
+ /** Resolve the listen port from DRAWLATCH_PORT (env override) or fall back to
68
+ * the configured port. Guards against non-numeric env values that would
69
+ * otherwise be silently coerced to NaN by parseInt. */
70
+ function resolvePort(envValue, fallback) {
71
+ if (envValue === undefined)
72
+ return fallback;
73
+ const parsed = parseInt(envValue, 10);
74
+ if (Number.isNaN(parsed)) {
75
+ console.warn(`[remote] Ignoring DRAWLATCH_PORT="${envValue}" (not a number); falling back to configured port ${fallback}`);
76
+ return fallback;
77
+ }
78
+ return parsed;
79
+ }
80
+ /** Loopback guard — used by /sync/listen, /sync/status, /events/stream, /admin.
81
+ * Hoisted to module scope so the admin router and its tests can reuse it. */
82
+ export function requireLoopback(req, res, next) {
83
+ const addr = req.socket.remoteAddress;
84
+ if (addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1') {
85
+ next();
86
+ }
87
+ else {
88
+ res.status(403).json({ error: 'Forbidden: local access only' });
89
+ }
90
+ }
91
+ /** Active sync session (at most one at a time). */
92
+ let activeSyncSession = null;
42
93
  // ── Helpers ────────────────────────────────────────────────────────────────
43
94
  /**
44
95
  * Load authorized peers from per-caller config.
45
- * Each caller specifies its own peerKeyDir containing signing.pub.pem + exchange.pub.pem.
96
+ * Caller public keys are loaded from keys/callers/<alias>/.
46
97
  */
47
98
  function loadCallerPeers(callers) {
48
99
  const peers = [];
100
+ const callersDir = getCallerKeysDir();
49
101
  for (const [alias, caller] of Object.entries(callers)) {
50
- if (!fs.existsSync(caller.peerKeyDir)) {
51
- console.error(`[remote] Peer key dir not found for "${alias}": ${caller.peerKeyDir}`);
102
+ const keysDir = path.join(callersDir, alias);
103
+ if (!fs.existsSync(keysDir)) {
104
+ console.error(`[remote] Caller keys not found for "${alias}": ${keysDir}`);
52
105
  continue;
53
106
  }
54
107
  try {
55
- peers.push({ alias, name: caller.name, keys: loadPublicKeys(caller.peerKeyDir) });
108
+ peers.push({ alias, name: caller.name, keys: loadPublicKeys(keysDir) });
56
109
  console.log(`[remote] Loaded authorized peer: ${alias}`);
57
110
  }
58
111
  catch (err) {
59
112
  console.error(`[remote] Failed to load peer ${alias}:`, err);
60
113
  }
61
114
  }
115
+ if (peers.length === 0 && Object.keys(callers).length > 0) {
116
+ console.error('[remote] WARNING: No authorized peers loaded. No clients will be able to connect.');
117
+ console.error('[remote] Check peer key directories in remote.config.json.');
118
+ }
62
119
  return peers;
63
120
  }
64
121
  function auditLog(sessionId, action, details = {}) {
@@ -70,35 +127,11 @@ function auditLog(sessionId, action, details = {}) {
70
127
  };
71
128
  console.log(`[audit] ${JSON.stringify(entry)}`);
72
129
  }
73
- export function isEndpointAllowed(url, patterns) {
74
- if (patterns.length === 0)
75
- return true; // no restrictions if empty
76
- return patterns.some((pattern) => {
77
- // Support simple glob patterns: * matches anything within a segment, ** matches across segments
78
- const regex = new RegExp('^' +
79
- pattern
80
- .replace(/[.+?^${}()|[\]\\]/g, '\\$&')
81
- .replace(/\*\*/g, '.__DOUBLE_STAR__.')
82
- .replace(/\*/g, '[^/]*')
83
- .replace(/\.__DOUBLE_STAR__\./g, '.*') +
84
- '$');
85
- return regex.test(url);
86
- });
87
- }
88
130
  // Re-export resolvePlaceholders from config for backward compatibility with tests
89
131
  export { resolvePlaceholders } from '../shared/config.js';
90
- /**
91
- * Find the first route whose allowedEndpoints match the given URL.
92
- * Routes with empty allowedEndpoints match nothing.
93
- */
94
- export function matchRoute(url, routes) {
95
- for (const route of routes) {
96
- if (route.allowedEndpoints.length > 0 && isEndpointAllowed(url, route.allowedEndpoints)) {
97
- return route;
98
- }
99
- }
100
- return null;
101
- }
132
+ // Re-export the canonical tool-dispatch surface (item D) so existing importers
133
+ // of './server.js' keep working after the extraction to './tool-dispatch.js'.
134
+ export { toolHandlers, executeProxyRequest, isEndpointAllowed, matchRoute, } from './tool-dispatch.js';
102
135
  export function checkRateLimit(session, limit) {
103
136
  const now = Date.now();
104
137
  const windowMs = 60_000;
@@ -135,162 +168,120 @@ setInterval(() => {
135
168
  cleanupSessions(sessions, pendingHandshakes);
136
169
  }, 60_000);
137
170
  /**
138
- * Core proxy request execution route matching, secret injection, and fetch.
171
+ * Project the active sessions into a sanitized snapshot for read-only use.
139
172
  *
140
- * Used by:
141
- * - The remote server's `http_request` tool handler (this file)
142
- * - callboard's `LocalProxy` class (in-process, no encryption)
143
- *
144
- * Pure in the sense that it takes routes as input rather than reading global state.
145
- * The only side effect is the outbound fetch().
173
+ * Drops `channel` (holds AES keys) and `resolvedRoutes` (carry decrypted
174
+ * secrets). Used by the loopback /admin API; never call res.json(session).
146
175
  */
147
- export async function executeProxyRequest(input, routes) {
148
- const { method, url, headers = {}, body } = input;
149
- // Step 1: Find matching route — try raw URL first
150
- let matched = matchRoute(url, routes);
151
- let resolvedUrl = url;
152
- if (matched) {
153
- // Resolve URL placeholders using matched route's secrets
154
- resolvedUrl = resolvePlaceholders(url, matched.secrets);
155
- }
156
- else {
157
- // Try resolving URL with each route's secrets to find a match
158
- for (const route of routes) {
159
- if (route.allowedEndpoints.length === 0)
160
- continue;
161
- const candidateUrl = resolvePlaceholders(url, route.secrets);
162
- if (isEndpointAllowed(candidateUrl, route.allowedEndpoints)) {
163
- matched = route;
164
- resolvedUrl = candidateUrl;
165
- break;
166
- }
167
- }
168
- }
169
- if (!matched) {
170
- throw new Error(`Endpoint not allowed: ${url}`);
171
- }
172
- // Step 2: Resolve client headers using matched route's secrets
173
- const resolvedHeaders = {};
174
- for (const [k, v] of Object.entries(headers)) {
175
- resolvedHeaders[k] = resolvePlaceholders(v, matched.secrets);
176
- }
177
- // Step 3: Check for header conflicts — reject if client provides a header
178
- // that conflicts with a route-level header (case-insensitive)
179
- const routeHeaderKeys = new Set(Object.keys(matched.headers).map((k) => k.toLowerCase()));
180
- for (const clientKey of Object.keys(resolvedHeaders)) {
181
- if (routeHeaderKeys.has(clientKey.toLowerCase())) {
182
- throw new Error(`Header conflict: client-provided header "${clientKey}" conflicts with a route-level header. Remove it from the request.`);
183
- }
184
- }
185
- // Step 4: Merge route-level headers (they take effect after conflict check)
186
- for (const [k, v] of Object.entries(matched.headers)) {
187
- resolvedHeaders[k] = v;
188
- }
189
- // Step 5: Resolve body placeholders using matched route's secrets.
190
- // Only when the route explicitly opts in via resolveSecretsInBody — prevents
191
- // exfiltration of secrets by writing placeholder strings into API resources
192
- // and reading them back.
193
- let resolvedBody;
194
- if (typeof body === 'string') {
195
- resolvedBody = matched.resolveSecretsInBody ? resolvePlaceholders(body, matched.secrets) : body;
176
+ export function getSessionsSnapshot() {
177
+ const out = [];
178
+ for (const [id, s] of sessions) {
179
+ out.push({
180
+ sessionIdShort: id.substring(0, 12),
181
+ callerAlias: s.callerAlias,
182
+ createdAt: s.createdAt,
183
+ lastActivity: s.lastActivity,
184
+ requestCount: s.requestCount,
185
+ windowRequests: s.windowRequests,
186
+ windowStart: s.windowStart,
187
+ });
196
188
  }
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';
189
+ return out;
190
+ }
191
+ // ── Session route invalidation ────────────────────────────────────────────
192
+ /**
193
+ * Re-resolve routes for all active sessions belonging to a caller.
194
+ * Called after secrets or connection list changes to ensure the session
195
+ * uses up-to-date resolved routes without requiring a reconnection.
196
+ */
197
+ function refreshCallerSessions(callerAlias) {
198
+ const config = loadRemoteConfig();
199
+ const caller = config.callers[callerAlias];
200
+ if (!caller)
201
+ return;
202
+ const callerRoutes = resolveCallerRoutes(config, callerAlias);
203
+ const callerEnvResolved = resolveSecrets(caller.env ?? {});
204
+ const callerResolvedRoutes = resolveRoutes(callerRoutes, callerEnvResolved, callerAlias);
205
+ for (const session of sessions.values()) {
206
+ if (session.callerAlias === callerAlias) {
207
+ session.resolvedRoutes = callerResolvedRoutes;
204
208
  }
205
209
  }
206
- // Step 6: Final endpoint check on fully resolved URL
207
- if (!isEndpointAllowed(resolvedUrl, matched.allowedEndpoints)) {
208
- throw new Error(`Endpoint not allowed after resolution: ${url}`);
209
- }
210
- // Step 7: Make the actual HTTP request
211
- const resp = await fetch(resolvedUrl, {
212
- method,
213
- headers: resolvedHeaders,
214
- body: resolvedBody,
215
- });
216
- const contentType = resp.headers.get('content-type') ?? '';
217
- let responseBody;
218
- if (contentType.includes('application/json')) {
219
- responseBody = await resp.json();
220
- }
221
- else {
222
- responseBody = await resp.text();
223
- }
224
- return {
225
- status: resp.status,
226
- statusText: resp.statusText,
227
- headers: Object.fromEntries(resp.headers.entries()),
228
- body: responseBody,
229
- };
230
210
  }
231
- const toolHandlers = {
232
- /**
233
- * Proxied HTTP request with route-scoped secret injection.
234
- * Delegates to the extracted executeProxyRequest() function.
235
- */
236
- async http_request(input, routes, _context) {
237
- return executeProxyRequest(input, routes);
238
- },
239
- /**
240
- * List available routes with metadata, endpoint patterns, and secret names (not values).
241
- * Provides full disclosure of available routes for the local agent.
242
- */
243
- list_routes(_input, routes, _context) {
244
- const routeList = routes.map((route, index) => {
245
- const info = { index };
246
- if (route.name)
247
- info.name = route.name;
248
- if (route.description)
249
- info.description = route.description;
250
- if (route.docsUrl)
251
- info.docsUrl = route.docsUrl;
252
- if (route.openApiUrl)
253
- info.openApiUrl = route.openApiUrl;
254
- info.allowedEndpoints = route.allowedEndpoints;
255
- info.secretNames = Object.keys(route.secrets);
256
- info.autoHeaders = Object.keys(route.headers);
257
- return info;
258
- });
259
- return Promise.resolve(routeList);
260
- },
261
- /**
262
- * Poll for new events from ingestors (Discord Gateway, webhooks, pollers).
263
- * Returns events since a cursor, optionally filtered by connection.
264
- */
265
- poll_events(input, _routes, context) {
266
- const { connection, after_id } = input;
267
- const afterId = after_id ?? -1;
268
- if (connection) {
269
- return Promise.resolve(context.ingestorManager.getEvents(context.callerAlias, connection, afterId));
270
- }
271
- return Promise.resolve(context.ingestorManager.getAllEvents(context.callerAlias, afterId));
272
- },
273
- /**
274
- * Get the status of all active ingestors for this caller.
275
- */
276
- ingestor_status(_input, _routes, context) {
277
- return Promise.resolve(context.ingestorManager.getStatuses(context.callerAlias));
278
- },
279
- };
280
211
  export function createApp(options = {}) {
281
212
  const app = express();
282
- // Parse JSON for handshake endpoints
283
- app.use('/handshake', express.json());
284
- // Raw buffer for encrypted request endpoint
285
- app.use('/request', express.raw({ type: 'application/octet-stream', limit: '10mb' }));
213
+ // Parse JSON for handshake endpoints (64kb limit — handshake messages are <2KB)
214
+ app.use('/handshake', express.json({ limit: '64kb' }));
215
+ // Raw buffer for encrypted request endpoint (50 MB to accommodate base64-encoded file uploads)
216
+ app.use('/request', express.raw({ type: 'application/octet-stream', limit: '50mb' }));
217
+ // Plain text for sync endpoint (AES-encrypted base64 body)
218
+ app.use('/sync', express.text({ type: 'text/plain', limit: '64kb' }));
219
+ // JSON for sync management endpoints (64kb limit — sync listen messages are tiny)
220
+ app.use('/sync/listen', express.json({ limit: '64kb' }));
221
+ // JSON for the loopback auto-enroll endpoint (item E).
222
+ app.use('/sync/auto-enroll', express.json({ limit: '64kb' }));
286
223
  // Raw buffer for webhook endpoints (needed for signature verification)
287
224
  app.use('/webhooks', express.raw({ type: 'application/json', limit: '1mb' }));
225
+ // ── Dashboard auth body/cookie parsers ───────────────────────────────────
226
+ // Scoped to the dashboard surface only — the daemon keeps its per-route
227
+ // body-parser pattern, so no global JSON parser is introduced. cookie-parser
228
+ // is needed by requireAuth on both /api/auth and /api/admin to read the
229
+ // session cookie; JSON parsing is needed only by the auth handlers.
230
+ app.use('/api', cookieParser());
231
+ app.use('/api/auth', express.json({ limit: '1mb' }));
232
+ // ── Per-IP rate limiting for pre-auth endpoints ──────────────────────────
233
+ // The per-session rate limit (checkRateLimit) only applies to authenticated
234
+ // /request calls. These limiters protect unauthenticated endpoints against
235
+ // volumetric abuse and brute-force attacks.
236
+ if (!options.disableRateLimiting) {
237
+ const ipRequestCounts = new Map();
238
+ function getIpRateLimit(ip, windowMs, max) {
239
+ const now = Date.now();
240
+ const key = `${ip}:${windowMs}:${max}`;
241
+ let entry = ipRequestCounts.get(key);
242
+ if (!entry || now - entry.windowStart > windowMs) {
243
+ entry = { windowStart: now, count: 0 };
244
+ ipRequestCounts.set(key, entry);
245
+ }
246
+ entry.count++;
247
+ const allowed = entry.count <= max;
248
+ return { allowed, remaining: Math.max(0, max - entry.count) };
249
+ }
250
+ function ipRateLimiter(windowMs, max) {
251
+ return (req, res, next) => {
252
+ const ip = req.ip ?? req.socket.remoteAddress ?? 'unknown';
253
+ const { allowed, remaining } = getIpRateLimit(ip, windowMs, max);
254
+ res.setHeader('X-RateLimit-Limit', max);
255
+ res.setHeader('X-RateLimit-Remaining', remaining);
256
+ if (!allowed) {
257
+ res.status(429).json({ error: 'Too many requests' });
258
+ return;
259
+ }
260
+ next();
261
+ };
262
+ }
263
+ app.use('/handshake', ipRateLimiter(60_000, 30));
264
+ app.use('/sync', ipRateLimiter(60_000, 10));
265
+ app.use('/webhooks', ipRateLimiter(60_000, 120));
266
+ app.use('/health', ipRateLimiter(60_000, 60));
267
+ // /api/admin is password-gated but the dashboard polls — give it more headroom than /health
268
+ app.use('/api/admin', ipRateLimiter(60_000, 300));
269
+ }
288
270
  const config = options.config ?? loadRemoteConfig();
289
- const ownKeys = options.ownKeys ?? loadKeyBundle(config.localKeysDir);
271
+ const ownKeys = options.ownKeys ?? loadKeyBundle(getServerKeysDir());
290
272
  const authorizedPeers = options.authorizedPeers ?? loadCallerPeers(config.callers);
273
+ // Capture for /admin/meta. Resolves the same way main() picks the listen port.
274
+ const startedAt = Date.now();
275
+ const port = resolvePort(process.env.DRAWLATCH_PORT, config.port);
291
276
  rateLimitPerMinute = config.rateLimitPerMinute;
292
- // Create or use the provided ingestor manager
293
- const ingestorManager = options.ingestorManager ?? new IngestorManager(config);
277
+ // Create or use the provided ingestor manager.
278
+ // When config is loaded from disk (production), pass loadRemoteConfig as the
279
+ // config loader so startOne()/restartOne() read fresh config, picking up
280
+ // changes made by tool handlers without requiring a server restart.
281
+ // When config is injected via options (tests), omit the loader so the
282
+ // IngestorManager uses the injected config snapshot.
283
+ const configLoader = options.config ? undefined : loadRemoteConfig;
284
+ const ingestorManager = options.ingestorManager ?? new IngestorManager(config, configLoader);
294
285
  app.locals.ingestorManager = ingestorManager;
295
286
  // Log connector and caller summary
296
287
  const connectorCount = config.connectors?.length ?? 0;
@@ -301,6 +292,30 @@ export function createApp(options = {}) {
301
292
  }
302
293
  console.log(`[remote] ${authorizedPeers.length} authorized peer(s)`);
303
294
  console.log(`[remote] Rate limit: ${rateLimitPerMinute} req/min per session`);
295
+ // Boot-time connection health table: check required secrets for each caller's connections
296
+ const templates = listConnectionTemplates();
297
+ const templateMap = new Map(templates.map((t) => [t.alias, t]));
298
+ for (const [callerAlias, caller] of Object.entries(config.callers)) {
299
+ const secretIssues = [];
300
+ for (const connName of caller.connections) {
301
+ const tpl = templateMap.get(connName);
302
+ if (!tpl)
303
+ continue; // custom connector, skip
304
+ for (const secret of tpl.requiredSecrets) {
305
+ const isSet = isSecretSetForCaller(secret, callerAlias, caller.env);
306
+ if (!isSet) {
307
+ secretIssues.push(` ${connName.padEnd(16)} ${secret.padEnd(28)} [NOT SET]`);
308
+ }
309
+ }
310
+ }
311
+ if (secretIssues.length > 0) {
312
+ console.log(`[remote] Connection secrets for "${callerAlias}":`);
313
+ for (const issue of secretIssues) {
314
+ console.log(issue);
315
+ }
316
+ console.log(`[remote] Set missing secrets in ${getEnvFilePath()}`);
317
+ }
318
+ }
304
319
  // ── Handshake init ─────────────────────────────────────────────────────
305
320
  app.post('/handshake/init', (req, res) => {
306
321
  try {
@@ -311,11 +326,14 @@ export function createApp(options = {}) {
311
326
  // Look up the caller alias by matching the returned PublicKeyBundle
312
327
  const matchedPeer = authorizedPeers.find((p) => p.keys === initiatorPubKey);
313
328
  const callerAlias = matchedPeer?.alias ?? 'unknown';
329
+ // Reload config from disk so new sessions pick up changes made by tool
330
+ // handlers (e.g. set_connection_enabled, set_secrets) without a restart.
331
+ const freshConfig = options.config ?? loadRemoteConfig();
314
332
  // Resolve per-caller routes (with optional env overrides)
315
- const callerRoutes = resolveCallerRoutes(config, callerAlias);
316
- const caller = config.callers[callerAlias];
333
+ const callerRoutes = resolveCallerRoutes(freshConfig, callerAlias);
334
+ const caller = freshConfig.callers[callerAlias];
317
335
  const callerEnvResolved = resolveSecrets(caller.env ?? {});
318
- const callerResolvedRoutes = resolveRoutes(callerRoutes, callerEnvResolved);
336
+ const callerResolvedRoutes = resolveRoutes(callerRoutes, callerEnvResolved, callerAlias);
319
337
  // Store pending handshake for the finish step
320
338
  pendingHandshakes.set(sessionKeys.sessionId, {
321
339
  responder,
@@ -414,6 +432,7 @@ export function createApp(options = {}) {
414
432
  const context = {
415
433
  callerAlias: session.callerAlias,
416
434
  ingestorManager: app.locals.ingestorManager,
435
+ refreshRoutes: () => refreshCallerSessions(session.callerAlias),
417
436
  };
418
437
  const result = await handler(request.toolInput, session.resolvedRoutes, context);
419
438
  // Build and encrypt response
@@ -427,6 +446,7 @@ export function createApp(options = {}) {
427
446
  const encrypted = session.channel.encryptJSON(response);
428
447
  auditLog(sessionId, 'response', {
429
448
  caller: session.callerAlias,
449
+ toolName: request.toolName,
430
450
  requestId: request.id,
431
451
  success: true,
432
452
  });
@@ -456,15 +476,303 @@ export function createApp(options = {}) {
456
476
  }
457
477
  }
458
478
  });
479
+ // ── Sync: key exchange endpoints ───────────────────────────────────────
480
+ // Internal management: open a sync session (called by drawlatch CLI)
481
+ app.post('/sync/listen', requireLoopback, (req, res) => {
482
+ const { inviteCode, confirmCode, encryptionKey, ttlMs } = req.body;
483
+ if (!inviteCode || !encryptionKey) {
484
+ res.status(400).json({ error: 'Missing inviteCode or encryptionKey' });
485
+ return;
486
+ }
487
+ if (activeSyncSession && isSyncSessionActive(activeSyncSession)) {
488
+ res.status(409).json({ error: 'A sync session is already active' });
489
+ return;
490
+ }
491
+ activeSyncSession = {
492
+ inviteCode,
493
+ confirmCode: confirmCode ?? null,
494
+ encryptionKey,
495
+ createdAt: Date.now(),
496
+ ttlMs: ttlMs ?? 5 * 60 * 1000,
497
+ completed: false,
498
+ failedAttempts: 0,
499
+ };
500
+ console.log('[sync] Sync session opened, waiting for callboard...');
501
+ res.json({ ok: true });
502
+ });
503
+ // Internal management: check sync session status (polled by CLI)
504
+ app.get('/sync/status', requireLoopback, (_req, res) => {
505
+ if (!activeSyncSession) {
506
+ res.json({ active: false, completed: false });
507
+ return;
508
+ }
509
+ const active = isSyncSessionActive(activeSyncSession);
510
+ res.json({
511
+ active,
512
+ completed: activeSyncSession.completed,
513
+ ...(activeSyncSession.result && {
514
+ callerAlias: activeSyncSession.result.callerAlias,
515
+ fingerprint: activeSyncSession.result.fingerprint,
516
+ }),
517
+ });
518
+ });
519
+ // External: called by callboard to exchange keys (encrypted body)
520
+ app.post('/sync', (req, res) => {
521
+ // Refuse all sync calls unless actively listening
522
+ if (!activeSyncSession || !isSyncSessionActive(activeSyncSession)) {
523
+ res.status(404).json({ error: 'NO_ACTIVE_SESSION' });
524
+ return;
525
+ }
526
+ const session = activeSyncSession;
527
+ // Decrypt the request body
528
+ let decrypted;
529
+ try {
530
+ decrypted = decryptSyncPayload(req.body, session.encryptionKey);
531
+ }
532
+ catch {
533
+ res.status(400).json({ error: 'DECRYPTION_FAILED' });
534
+ return;
535
+ }
536
+ // Validate payload shape
537
+ const validationError = validateSyncRequest(decrypted);
538
+ if (validationError) {
539
+ res.status(400).json({ error: 'INVALID_PAYLOAD', detail: validationError });
540
+ return;
541
+ }
542
+ const syncReq = decrypted;
543
+ // Validate invite code
544
+ if (syncReq.inviteCode !== session.inviteCode) {
545
+ session.failedAttempts++;
546
+ if (session.failedAttempts >= MAX_SYNC_ATTEMPTS) {
547
+ console.error('[sync] Too many failed attempts — invalidating session');
548
+ activeSyncSession = null;
549
+ }
550
+ res.status(403).json({ error: 'CODE_MISMATCH' });
551
+ return;
552
+ }
553
+ // Validate confirm code (must be set by CLI before callboard calls)
554
+ if (!session.confirmCode) {
555
+ res.status(403).json({ error: 'CODE_MISMATCH', detail: 'Confirm code not yet entered' });
556
+ return;
557
+ }
558
+ if (syncReq.confirmCode !== session.confirmCode) {
559
+ session.failedAttempts++;
560
+ if (session.failedAttempts >= MAX_SYNC_ATTEMPTS) {
561
+ console.error('[sync] Too many failed attempts — invalidating session');
562
+ activeSyncSession = null;
563
+ }
564
+ res.status(403).json({ error: 'CODE_MISMATCH' });
565
+ return;
566
+ }
567
+ // Check expiry
568
+ if (Date.now() - session.createdAt > session.ttlMs) {
569
+ activeSyncSession = null;
570
+ res.status(410).json({ error: 'SESSION_EXPIRED' });
571
+ return;
572
+ }
573
+ // Save callboard's public keys
574
+ const callerAlias = syncReq.callerAlias;
575
+ try {
576
+ importCallerPublicKeys(callerAlias, syncReq.publicKeys);
577
+ }
578
+ catch (err) {
579
+ const msg = err instanceof Error ? err.message : String(err);
580
+ res.status(400).json({ error: 'INVALID_PAYLOAD', detail: `Invalid public keys: ${msg}` });
581
+ return;
582
+ }
583
+ // Reload config from disk so we don't clobber changes made since startup
584
+ const freshConfig = options.config ?? loadRemoteConfig();
585
+ // Register caller in config if not already present
586
+ if (!(callerAlias in freshConfig.callers)) {
587
+ freshConfig.callers[callerAlias] = {
588
+ connections: [],
589
+ };
590
+ saveRemoteConfig(freshConfig);
591
+ console.log(`[sync] Registered new caller "${callerAlias}" (0 connections — configure manually)`);
592
+ }
593
+ else {
594
+ console.log(`[sync] Caller "${callerAlias}" already exists, updated peer keys`);
595
+ }
596
+ // Reload authorized peers so the new caller can connect immediately
597
+ const newPeer = loadCallerPeers({ [callerAlias]: freshConfig.callers[callerAlias] });
598
+ for (const p of newPeer) {
599
+ if (!authorizedPeers.find((existing) => existing.alias === p.alias)) {
600
+ authorizedPeers.push(p);
601
+ }
602
+ }
603
+ // Build response with remote server's public keys
604
+ const remotePublicKeys = exportServerPublicKeys();
605
+ const fp = callerFingerprint(callerAlias);
606
+ const syncResponse = {
607
+ remotePublicKeys,
608
+ callerAlias,
609
+ fingerprint: fp,
610
+ };
611
+ // Mark session as completed
612
+ session.completed = true;
613
+ session.result = { callerAlias, fingerprint: fp };
614
+ console.log(`[sync] Key exchange complete with "${callerAlias}" (fingerprint: ${fp})`);
615
+ const encryptedResponse = encryptSyncPayload(syncResponse, session.encryptionKey);
616
+ res.type('text/plain').send(encryptedResponse);
617
+ });
459
618
  // ── Health check (unencrypted, no secrets exposed) ─────────────────────
460
619
  app.get('/health', (_req, res) => {
461
620
  res.json({
462
621
  status: 'ok',
463
622
  activeSessions: sessions.size,
464
623
  uptime: process.uptime(),
624
+ // Public tunnel URL (not a secret) so `drawlatch status` and the start
625
+ // command's tunnel-URL probe can surface it without authenticating.
626
+ tunnelUrl: getTunnelUrl(),
627
+ });
628
+ });
629
+ // ── Event stream (loopback-only, for `drawlatch watch`) ─────────────
630
+ app.get('/events/stream', requireLoopback, (req, res) => {
631
+ res.writeHead(200, {
632
+ 'Content-Type': 'text/event-stream',
633
+ 'Cache-Control': 'no-cache',
634
+ Connection: 'keep-alive',
465
635
  });
636
+ res.flushHeaders();
637
+ const mgr = app.locals.ingestorManager;
638
+ const listener = (event) => {
639
+ res.write(`data: ${JSON.stringify(event)}\n\n`);
640
+ };
641
+ mgr.onEvent(listener);
642
+ req.on('close', () => {
643
+ mgr.offEvent(listener);
644
+ });
645
+ });
646
+ // ── Dashboard auth routes ────────────────────────────────────────────
647
+ // Public login/logout/check + auth-gated change-password. The scrypt
648
+ // password is the trust boundary for the dashboard surface, replacing the
649
+ // old loopback-only posture so it can be host-bound (DRAWLATCH_HOST=0.0.0.0)
650
+ // while staying password-protected.
651
+ //
652
+ // Two rate limiters (same config as the former drawlatch-ui backend):
653
+ // - 3/min for the auth-mutating endpoints (login + change-password)
654
+ // - 20/min for the cheap, polling-friendly endpoints (check + logout)
655
+ const loginLimiter = rateLimit({
656
+ windowMs: 60 * 1000,
657
+ max: 3,
658
+ standardHeaders: true,
659
+ legacyHeaders: false,
660
+ message: { error: 'Too many attempts. Try again in a minute.' },
661
+ });
662
+ const checkLimiter = rateLimit({
663
+ windowMs: 60 * 1000,
664
+ max: 20,
665
+ standardHeaders: true,
666
+ legacyHeaders: false,
667
+ message: { error: 'Too many requests. Try again in a minute.' },
668
+ });
669
+ app.post('/api/auth/login', loginLimiter, loginHandler);
670
+ app.post('/api/auth/logout', checkLimiter, logoutHandler);
671
+ app.get('/api/auth/check', checkLimiter, checkAuthHandler);
672
+ app.post('/api/auth/change-password', loginLimiter, requireAuth, changePasswordHandler);
673
+ // ── Admin API (password-gated) ────────────────────────────────────────
674
+ // Powers the merged dashboard at /api/admin/* (the path the frontend uses).
675
+ // Mounted behind requireAuth — the password gate replaces the old loopback
676
+ // guard. Read endpoints never expose secrets; mutating endpoints (item A) are
677
+ // write-only for secrets and live-reload routes/ingestors after every change.
678
+ // When no password is configured, requireAuth returns 503 and the SPA shows a
679
+ // locked state (the daemon must never exit just because it is unconfigured).
680
+ /** Resolve the live routes (with secrets) for a caller, for tool dispatch. */
681
+ const resolveRoutesForCaller = (alias) => {
682
+ const freshConfig = options.config ?? loadRemoteConfig();
683
+ const caller = freshConfig.callers[alias];
684
+ if (!caller)
685
+ return [];
686
+ const callerRoutes = resolveCallerRoutes(freshConfig, alias);
687
+ const callerEnvResolved = resolveSecrets(caller.env ?? {});
688
+ return resolveRoutes(callerRoutes, callerEnvResolved, alias);
689
+ };
690
+ /** Register or refresh the authorized peer for a (possibly new) caller. */
691
+ const reloadPeer = (alias) => {
692
+ const freshConfig = options.config ?? loadRemoteConfig();
693
+ const callerConfig = freshConfig.callers[alias];
694
+ if (!callerConfig)
695
+ return;
696
+ for (const p of loadCallerPeers({ [alias]: callerConfig })) {
697
+ const idx = authorizedPeers.findIndex((e) => e.alias === p.alias);
698
+ if (idx >= 0)
699
+ authorizedPeers[idx] = p;
700
+ else
701
+ authorizedPeers.push(p);
702
+ }
703
+ };
704
+ /** Drop the authorized peer + any active sessions for a deleted caller. */
705
+ const removePeer = (alias) => {
706
+ const idx = authorizedPeers.findIndex((e) => e.alias === alias);
707
+ if (idx >= 0)
708
+ authorizedPeers.splice(idx, 1);
709
+ for (const [id, s] of sessions) {
710
+ if (s.callerAlias === alias)
711
+ sessions.delete(id);
712
+ }
713
+ };
714
+ // ── Loopback auto-enroll (item E) ─────────────────────────────────────
715
+ // A co-located client that shares our filesystem proves co-location by
716
+ // presenting the one-time enroll token drawlatch wrote into the config dir,
717
+ // and gets a caller provisioned (with keys) without the invite-code dance.
718
+ // Loopback-only: never reachable from off-box.
719
+ app.post('/sync/auto-enroll', requireLoopback, (req, res) => {
720
+ const { token, alias, name } = (req.body ?? {});
721
+ if (!token || !alias) {
722
+ res.status(400).json({ error: 'Missing token or alias' });
723
+ return;
724
+ }
725
+ try {
726
+ const result = autoEnroll(token, alias, name !== undefined ? { name } : {});
727
+ reloadPeer(alias);
728
+ res.json({
729
+ alias: result.alias,
730
+ name: result.name,
731
+ fingerprint: result.fingerprint,
732
+ keysDir: result.keysDir,
733
+ connections: result.connections,
734
+ });
735
+ }
736
+ catch (err) {
737
+ const message = err instanceof Error ? err.message : String(err);
738
+ const status = /invalid or expired/i.test(message)
739
+ ? 403
740
+ : /already exists/i.test(message)
741
+ ? 409
742
+ : 400;
743
+ res.status(status).json({ error: message });
744
+ }
466
745
  });
746
+ // JSON body parsing for the mutating admin endpoints (cookie-parser for /api
747
+ // is already installed above; the daemon keeps its per-route parser pattern).
748
+ app.use('/api/admin', express.json({ limit: '1mb' }));
749
+ app.use('/api/admin', requireAuth, createAdminRouter({
750
+ getSessionsSnapshot,
751
+ ingestorManager: () => app.locals.ingestorManager,
752
+ loadConfig: () => options.config ?? loadRemoteConfig(),
753
+ resolveRoutesForCaller,
754
+ refreshCaller: refreshCallerSessions,
755
+ reloadPeer,
756
+ removePeer,
757
+ version: PKG_VERSION,
758
+ port,
759
+ startedAt,
760
+ }));
467
761
  // ── Webhook receiver ─────────────────────────────────────────────────
762
+ // Trello (and potentially other services) send a HEAD request to the
763
+ // callback URL to verify it is reachable before activating the webhook.
764
+ // Respond with 200 if at least one ingestor is registered for the path.
765
+ app.head('/webhooks/:path', (req, res) => {
766
+ const webhookPath = req.params.path;
767
+ const mgr = app.locals.ingestorManager;
768
+ const ingestors = mgr.getWebhookIngestors(webhookPath);
769
+ if (ingestors.length === 0) {
770
+ res.status(404).end();
771
+ }
772
+ else {
773
+ res.status(200).end();
774
+ }
775
+ });
468
776
  app.post('/webhooks/:path', (req, res) => {
469
777
  const webhookPath = req.params.path;
470
778
  const mgr = app.locals.ingestorManager;
@@ -492,37 +800,203 @@ export function createApp(options = {}) {
492
800
  res.status(200).json({ received: true });
493
801
  }
494
802
  else {
495
- res.status(403).json({ error: 'Webhook rejected by all ingestors', details: results });
803
+ // Log details server-side for debugging; never expose ingestor internals to callers
804
+ console.error('[remote] Webhook rejected by all ingestors:', JSON.stringify(results));
805
+ res.status(403).json({ error: 'Webhook rejected' });
496
806
  }
497
807
  });
808
+ // ── SPA serving ───────────────────────────────────────────────────────────
809
+ // Mounted LAST, after every API and protocol route. Static assets are served
810
+ // from frontend/dist; any unmatched GET/HEAD falls back to index.html so
811
+ // client-side deep links resolve on reload.
812
+ //
813
+ // Express 5 removed string-pattern wildcards (`app.get("*", …)` throws), so
814
+ // the SPA fallback is a terminal middleware instead. It skips the API and
815
+ // protocol prefixes so unmatched routes there return their own status (e.g.
816
+ // a JSON 404 from the admin router) rather than index.html.
817
+ //
818
+ // The mount is gated on the bundle existing on disk (rather than NODE_ENV),
819
+ // so it works in any context where the SPA has been built — production
820
+ // installs, local `npm start`, or a dev clone after `npm run build:frontend`.
821
+ // In a pure dev workflow you should still use `vite` on its own port for
822
+ // hot reload; this mount just makes hitting the daemon port directly do
823
+ // something useful.
824
+ const distDir = fileURLToPath(new URL('../../frontend/dist', import.meta.url));
825
+ const indexHtml = path.join(distDir, 'index.html');
826
+ if (fs.existsSync(indexHtml)) {
827
+ const apiPrefixes = [
828
+ '/api',
829
+ '/handshake',
830
+ '/request',
831
+ '/sync',
832
+ '/events',
833
+ '/webhooks',
834
+ '/health',
835
+ ];
836
+ app.use(express.static(distDir));
837
+ app.use((req, res, next) => {
838
+ if (req.method !== 'GET' && req.method !== 'HEAD') {
839
+ next();
840
+ return;
841
+ }
842
+ if (apiPrefixes.some((p) => req.path === p || req.path.startsWith(`${p}/`))) {
843
+ next();
844
+ return;
845
+ }
846
+ // IMPORTANT: pass `root` instead of an absolute path. Express 5 / send 1.x
847
+ // walk the absolute path components looking for "dotfiles" (segments
848
+ // starting with "."), and the default dotfile policy is "ignore" — which
849
+ // turns into a 404. When drawlatch is bundled inside callboard installed
850
+ // under `~/.nvm/...`, the `.nvm` segment matches the dotfile rule and
851
+ // every SPA deep-link reload becomes "Not Found". Routing the file
852
+ // through `root` makes send only inspect the relative path's parts
853
+ // (`./index.html`), so the dotfile guard never trips.
854
+ res.sendFile('index.html', { root: distDir });
855
+ });
856
+ }
857
+ else {
858
+ console.warn(`[remote] Dashboard bundle not found at ${distDir}. ` +
859
+ `Run \`npm run build:frontend\` (or reinstall drawlatch) to enable the dashboard UI; ` +
860
+ `the daemon API will still work.`);
861
+ }
498
862
  return app;
499
863
  }
864
+ // waitForTunnelReady is now exported from tunnel.ts — imported dynamically
865
+ // alongside startTunnel in the tunnel startup block below.
500
866
  // ── Start ──────────────────────────────────────────────────────────────────
501
867
  export function main() {
868
+ console.log('[remote] Starting drawlatch server...');
869
+ // Own our on-disk layout: migrate any legacy key directories before loading
870
+ // keys/config (item G). Idempotent and safe on every startup.
871
+ migrateConfigDir();
872
+ // Pre-flight validation: check for common setup issues before starting
873
+ const remoteConfigPath = getRemoteConfigPath();
874
+ if (!fs.existsSync(remoteConfigPath)) {
875
+ console.error(`[remote] Error: No remote config found at ${remoteConfigPath}`);
876
+ console.error('[remote] Run: drawlatch init');
877
+ process.exit(1);
878
+ }
502
879
  const config = loadRemoteConfig();
503
- const port = process.env.DRAWLATCH_PORT ? parseInt(process.env.DRAWLATCH_PORT, 10) : config.port;
880
+ const serverKeysDirPath = getServerKeysDir();
881
+ const requiredKeyFiles = [
882
+ 'signing.key.pem',
883
+ 'signing.pub.pem',
884
+ 'exchange.key.pem',
885
+ 'exchange.pub.pem',
886
+ ];
887
+ const missingKeyFiles = requiredKeyFiles.filter((f) => !fs.existsSync(path.join(serverKeysDirPath, f)));
888
+ if (missingKeyFiles.length > 0) {
889
+ if (!fs.existsSync(serverKeysDirPath)) {
890
+ console.error(`[remote] Error: Server keys not found at ${serverKeysDirPath}`);
891
+ }
892
+ else {
893
+ console.error(`[remote] Error: Incomplete server keys in ${serverKeysDirPath}`);
894
+ console.error(`[remote] Missing: ${missingKeyFiles.join(', ')}`);
895
+ }
896
+ console.error('[remote] Run: drawlatch generate-keys server');
897
+ process.exit(1);
898
+ }
899
+ if (Object.keys(config.callers).length === 0) {
900
+ console.log('[remote] No callers configured — server will accept sync requests.');
901
+ console.log('[remote] To add callers, run: drawlatch sync');
902
+ }
903
+ // Drop a one-time enroll token into the config dir so a co-located client
904
+ // that shares our filesystem can auto-enroll a caller (item E).
905
+ try {
906
+ writeEnrollToken();
907
+ }
908
+ catch (err) {
909
+ console.warn('[remote] Could not write enroll token:', err);
910
+ }
911
+ const port = resolvePort(process.env.DRAWLATCH_PORT, config.port);
504
912
  const host = process.env.DRAWLATCH_HOST ?? config.host;
913
+ // Self-managed tunnel: config flag OR env override (item C).
914
+ const useTunnel = config.tunnel === true || process.env.DRAWLATCH_TUNNEL === '1';
505
915
  const app = createApp();
506
916
  const ingestorManager = app.locals.ingestorManager;
507
- const server = app.listen(port, host, () => {
917
+ // Holds the tunnel stop function if a tunnel is active (set inside the
918
+ // listen callback, read by the shutdown handler — both share this scope).
919
+ let stopTunnel;
920
+ const server = app.listen(port, host, () => void (async () => {
508
921
  console.log(`[remote] Secure remote server listening on ${host}:${port}`);
509
- // Start ingestors after the server is listening
922
+ console.log(`[remote] PID: ${process.pid}, Node: ${process.version}`);
923
+ // If a tunnel was requested, start it before ingestors so that
924
+ // process.env.DRAWLATCH_TUNNEL_URL is available during secret resolution.
925
+ if (useTunnel) {
926
+ try {
927
+ const { startTunnel, waitForTunnelReady } = await import('./tunnel.js');
928
+ const tunnel = await startTunnel({ port, host });
929
+ stopTunnel = tunnel.stop;
930
+ process.env.DRAWLATCH_TUNNEL_URL = tunnel.url;
931
+ setTunnelUrl(tunnel.url);
932
+ // Auto-populate callback URL env vars for webhook ingestors whose
933
+ // connection templates reference an env var that is not yet set.
934
+ for (const [callerAlias, _callerConfig] of Object.entries(config.callers)) {
935
+ const rawRoutes = resolveCallerRoutes(config, callerAlias);
936
+ for (const route of rawRoutes) {
937
+ const callbackTpl = route.ingestor?.webhook?.callbackUrl;
938
+ const webhookPath = route.ingestor?.webhook?.path;
939
+ if (!callbackTpl || !webhookPath)
940
+ continue;
941
+ // Extract env var name from "${VAR}" pattern
942
+ const match = /^\$\{(\w+)\}$/.exec(callbackTpl);
943
+ if (match) {
944
+ const envVar = match[1];
945
+ const fullUrl = `${tunnel.url}/webhooks/${webhookPath}`;
946
+ // Set bare env var
947
+ if (!process.env[envVar]) {
948
+ process.env[envVar] = fullUrl;
949
+ console.log(`[remote] Auto-set ${envVar}=${fullUrl}`);
950
+ }
951
+ // Also set prefixed env var so caller-scoped secret resolution
952
+ // (which checks PREFIX_VAR, not bare VAR) can find it.
953
+ const prefix = callerAlias.toUpperCase().replace(/-/g, '_');
954
+ const prefixedEnvVar = `${prefix}_${envVar}`;
955
+ if (!process.env[prefixedEnvVar]) {
956
+ process.env[prefixedEnvVar] = fullUrl;
957
+ console.log(`[remote] Auto-set ${prefixedEnvVar}=${fullUrl}`);
958
+ }
959
+ }
960
+ }
961
+ }
962
+ console.log(`[remote] Tunnel active: ${tunnel.url}`);
963
+ console.log(`[remote] Webhook URL: ${tunnel.url}/webhooks/<path>`);
964
+ // Wait for the tunnel to be fully connected before starting ingestors.
965
+ // cloudflared reports the URL before the QUIC connection is established;
966
+ // services like Trello validate the callback URL during registration.
967
+ await waitForTunnelReady(tunnel.url, 10_000);
968
+ }
969
+ catch (err) {
970
+ console.error('[remote] Failed to start tunnel:', err);
971
+ console.error('[remote] Continuing without tunnel. Webhooks will only work on localhost.');
972
+ }
973
+ }
974
+ // Start ingestors after tunnel (if any) is ready
510
975
  ingestorManager.startAll().catch((err) => {
511
976
  console.error('[remote] Failed to start ingestors:', err);
512
977
  });
513
- });
514
- // Graceful shutdown: stop ingestors, then close the server.
978
+ })());
979
+ // Graceful shutdown: stop tunnel, then ingestors, then close the server.
515
980
  const shutdown = () => {
516
981
  console.log('[remote] Shutting down gracefully...');
517
- ingestorManager
518
- .stopAll()
519
- .catch((err) => {
520
- console.error('[remote] Error stopping ingestors:', err);
521
- })
522
- .finally(() => {
523
- server.close(() => {
524
- console.log('[remote] Server closed.');
525
- process.exit(0);
982
+ // Stop tunnel first (fast — just kills a child process)
983
+ setTunnelUrl(null);
984
+ const tunnelDone = stopTunnel
985
+ ? stopTunnel().catch((err) => {
986
+ console.error('[remote] Error stopping tunnel:', err);
987
+ })
988
+ : Promise.resolve();
989
+ void tunnelDone.then(() => {
990
+ ingestorManager
991
+ .stopAll()
992
+ .catch((err) => {
993
+ console.error('[remote] Error stopping ingestors:', err);
994
+ })
995
+ .finally(() => {
996
+ server.close(() => {
997
+ console.log('[remote] Server closed.');
998
+ process.exit(0);
999
+ });
526
1000
  });
527
1001
  });
528
1002
  // Force exit after 10 seconds if connections don't drain
@@ -531,6 +1005,18 @@ export function main() {
531
1005
  process.exit(1);
532
1006
  }, 10_000).unref();
533
1007
  };
1008
+ server.on('error', (err) => {
1009
+ if (err.code === 'EADDRINUSE') {
1010
+ console.error(`[remote] Error: Port ${port} is already in use.`);
1011
+ }
1012
+ else if (err.code === 'EACCES') {
1013
+ console.error(`[remote] Error: Permission denied for ${host}:${port}. Try a port >= 1024.`);
1014
+ }
1015
+ else {
1016
+ console.error(`[remote] Server error:`, err);
1017
+ }
1018
+ process.exit(1);
1019
+ });
534
1020
  process.on('SIGTERM', shutdown);
535
1021
  process.on('SIGINT', shutdown);
536
1022
  }