@wolpertingerlabs/drawlatch 1.0.0-alpha.15.2 → 1.0.0-alpha.19

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.
package/CONNECTIONS.md CHANGED
@@ -23,6 +23,7 @@ Connection templates are loaded when a caller's session is established. Custom c
23
23
 
24
24
  | Connection | API | Required Environment Variable(s) | Auth Method |
25
25
  | --------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------ | ------------------------------------- |
26
+ | `agentmail` | [AgentMail API](https://docs.agentmail.to/api-reference) | `AGENTMAIL_API_KEY` | Bearer token header (see note) |
26
27
  | `anthropic` | [Anthropic Claude API](https://docs.anthropic.com/en/api) | `ANTHROPIC_API_KEY` | x-api-key header (see note) |
27
28
  | `bluesky` | [Bluesky API (AT Protocol)](https://docs.bsky.app/) | `BLUESKY_ACCESS_TOKEN` | Bearer token header (see note) |
28
29
  | `devin` | [Devin AI API](https://docs.devin.ai/api-reference/overview) | `DEVIN_API_KEY` | Bearer token header |
@@ -46,6 +47,8 @@ Connection templates are loaded when a caller's session is established. Custom c
46
47
  | `twitch` | [Twitch Helix API](https://dev.twitch.tv/docs/api/reference/) | `TWITCH_ACCESS_TOKEN`, `TWITCH_CLIENT_ID` | Bearer + Client-Id headers (see note) |
47
48
  | `x` | [X (Twitter) API v2](https://developer.x.com/en/docs/x-api) | `X_BEARER_TOKEN` | Bearer token header (see note) |
48
49
 
50
+ > **AgentMail note:** AgentMail provides email infrastructure for AI agents (inboxes, messages, threads, drafts). All endpoints live under the `/v0` path on `api.agentmail.to` and use a standard `Authorization: Bearer ${AGENTMAIL_API_KEY}` header. Create an API key in the [AgentMail Console](https://console.agentmail.to). AgentMail can deliver inbound email in real time via Svix-signed webhooks, but that is not yet wired up as a drawlatch ingestor — this connection currently covers the REST API only.
51
+
49
52
  > **Anthropic note:** The Anthropic API uses a custom `x-api-key` header instead of the standard `Authorization: Bearer` pattern. The `anthropic-version` header is pinned to `2023-06-01`. To use a different API version, override with a custom route.
50
53
 
51
54
  > **GitHub note:** The `github` connection includes a **webhook ingestor** for real-time events (push, pull_request, issues, etc.). Set `GITHUB_WEBHOOK_SECRET` to the webhook signing secret configured in your GitHub repository's webhook settings, then point the webhook URL to `https://<your-server>/webhooks/github`. Events are buffered and retrievable via `poll_events`. The server must be publicly accessible (or behind a tunnel like ngrok/Cloudflare Tunnel) to receive webhook POSTs. If you don't need webhook ingestion, the `GITHUB_WEBHOOK_SECRET` env var can be left unset — the REST API functionality works independently.
package/README.md CHANGED
@@ -2,13 +2,13 @@
2
2
 
3
3
  > **Alpha Software:** Expect breaking changes between updates.
4
4
 
5
- Drawlatch is a config-driven proxy that gives AI agents authenticated access to external APIs. Define your connections and secrets in a single config file — agents get structured, allowlisted access to 22 pre-built APIs without ever seeing your credentials.
5
+ Drawlatch is a config-driven proxy that gives AI agents authenticated access to external APIs. Define your connections and secrets in a single config file — agents get structured, allowlisted access to 23 pre-built APIs without ever seeing your credentials.
6
6
 
7
7
  **Using [Callboard](https://github.com/WolpertingerLabs/callboard)?** Drawlatch is built in — Callboard manages connections, secrets, and agent identities through its UI. You don't need to set up drawlatch separately.
8
8
 
9
9
  ## Key Features
10
10
 
11
- - **22 pre-built connections** — GitHub, Slack, Discord, Stripe, Notion, Linear, OpenAI, and [more](CONNECTIONS.md)
11
+ - **23 pre-built connections** — GitHub, Slack, Discord, Stripe, Notion, Linear, OpenAI, and [more](CONNECTIONS.md)
12
12
  - **Endpoint allowlisting** — agents can only reach explicitly configured URL patterns
13
13
  - **Per-caller access control** — each agent identity sees only its assigned connections
14
14
  - **Real-time event ingestion** — WebSocket, webhook, and polling listeners for incoming events ([details](INGESTORS.md))
@@ -286,7 +286,7 @@ Useful for CI environments or running multiple independent setups on the same ma
286
286
 
287
287
  ## Connections
288
288
 
289
- 22 pre-built connection templates ship with drawlatch. Reference them by name in a caller's `connections` list:
289
+ 23 pre-built connection templates ship with drawlatch. Reference them by name in a caller's `connections` list:
290
290
 
291
291
  | Connection | API | Required Env Var(s) |
292
292
  |------------|-----|---------------------|
@@ -486,7 +486,7 @@ npm run dev:mcp # MCP proxy with hot reload
486
486
  ```
487
487
  src/
488
488
  ├── cli/ # Key generation CLI
489
- ├── connections/ # 22 pre-built route templates (JSON)
489
+ ├── connections/ # 23 pre-built route templates (JSON)
490
490
  ├── mcp/server.ts # Local MCP proxy (stdio transport)
491
491
  ├── remote/
492
492
  │ ├── server.ts # Remote secure server (Express)
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "AgentMail API",
3
+ "stability": "beta",
4
+ "category": "messaging",
5
+ "description": "AgentMail API — email infrastructure for AI agents. Create and manage inboxes, send and reply to messages, browse threads, and manage drafts and attachments. Auth is handled automatically via the AGENTMAIL_API_KEY environment variable using a Bearer token. All endpoints are under the /v0 path. Create an API key in the AgentMail Console (https://console.agentmail.to).",
6
+ "docsUrl": "https://docs.agentmail.to/api-reference",
7
+ "headers": {
8
+ "Authorization": "Bearer ${AGENTMAIL_API_KEY}",
9
+ "Content-Type": "application/json"
10
+ },
11
+ "secrets": {
12
+ "AGENTMAIL_API_KEY": "${AGENTMAIL_API_KEY}"
13
+ },
14
+ "allowedEndpoints": [
15
+ "https://api.agentmail.to/**"
16
+ ],
17
+ "testConnection": {
18
+ "url": "https://api.agentmail.to/v0/inboxes",
19
+ "description": "Lists inboxes to verify the API key"
20
+ }
21
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Loopback-only, read-only admin HTTP API for the drawlatch-ui dashboard.
3
+ *
4
+ * SECURITY non-negotiables (mirrored in /admin.test.ts):
5
+ * - Never serializes Session.channel (AES keys) or ResolvedRoute.secrets.
6
+ * - Never returns process.env values; only key names. Secret presence is
7
+ * reported via `isSecretSetForCaller()` (booleans only).
8
+ * - Caller `env` mappings (e.g. "GITHUB_TOKEN": "${ACME_GITHUB_TOKEN}") are
9
+ * reduced to key-name lists — the value strings are NOT returned.
10
+ * - No mutations: every endpoint is GET.
11
+ * - No CORS. Loopback IS the trust boundary; the UI's local backend proxies.
12
+ */
13
+ import express from 'express';
14
+ import { type RemoteServerConfig } from '../shared/config.js';
15
+ import type { IngestorManager } from './ingestors/index.js';
16
+ import type { SessionSnapshot } from './server.js';
17
+ export interface AdminRouterDeps {
18
+ /** Sanitized session snapshot — see Session in server.ts. */
19
+ getSessionsSnapshot: () => SessionSnapshot[];
20
+ /** Late-bound so tests can swap the manager without rebuilding the router. */
21
+ ingestorManager: () => IngestorManager;
22
+ /** Late-bound so changes to disk config are picked up between requests. */
23
+ loadConfig: () => RemoteServerConfig;
24
+ version: string;
25
+ port: number;
26
+ startedAt: number;
27
+ }
28
+ export declare function createAdminRouter(deps: AdminRouterDeps): express.Router;
29
+ //# sourceMappingURL=admin.d.ts.map
@@ -0,0 +1,232 @@
1
+ /**
2
+ * Loopback-only, read-only admin HTTP API for the drawlatch-ui dashboard.
3
+ *
4
+ * SECURITY non-negotiables (mirrored in /admin.test.ts):
5
+ * - Never serializes Session.channel (AES keys) or ResolvedRoute.secrets.
6
+ * - Never returns process.env values; only key names. Secret presence is
7
+ * reported via `isSecretSetForCaller()` (booleans only).
8
+ * - Caller `env` mappings (e.g. "GITHUB_TOKEN": "${ACME_GITHUB_TOKEN}") are
9
+ * reduced to key-name lists — the value strings are NOT returned.
10
+ * - No mutations: every endpoint is GET.
11
+ * - No CORS. Loopback IS the trust boundary; the UI's local backend proxies.
12
+ */
13
+ import express from 'express';
14
+ import fs from 'node:fs';
15
+ import { getCallerKeysDir, getServerKeysDir, getEnvFilePath, getRemoteConfigPath, } from '../shared/config.js';
16
+ import { listConnectionTemplates, loadConnection } from '../shared/connections.js';
17
+ import { isSecretSetForCaller } from '../shared/env-utils.js';
18
+ import { callerFingerprint } from '../shared/crypto/key-manager.js';
19
+ import path from 'node:path';
20
+ export function createAdminRouter(deps) {
21
+ const router = express.Router();
22
+ // ── /admin/meta ────────────────────────────────────────────────────────
23
+ router.get('/meta', (_req, res) => {
24
+ res.json({
25
+ version: deps.version,
26
+ port: deps.port,
27
+ pid: process.pid,
28
+ startedAt: deps.startedAt,
29
+ uptimeSec: Math.floor((Date.now() - deps.startedAt) / 1000),
30
+ configPath: getRemoteConfigPath(),
31
+ callerKeysDir: getCallerKeysDir(),
32
+ serverKeysDir: getServerKeysDir(),
33
+ envFilePath: getEnvFilePath(),
34
+ });
35
+ });
36
+ // ── /admin/health ──────────────────────────────────────────────────────
37
+ router.get('/health', (_req, res) => {
38
+ const statuses = deps.ingestorManager().getAllStatuses();
39
+ const counts = { connected: 0, error: 0, starting: 0, stopped: 0 };
40
+ for (const s of statuses) {
41
+ if (s.state === 'connected')
42
+ counts.connected++;
43
+ else if (s.state === 'error')
44
+ counts.error++;
45
+ else if (s.state === 'starting' || s.state === 'reconnecting')
46
+ counts.starting++;
47
+ else
48
+ counts.stopped++;
49
+ }
50
+ res.json({
51
+ status: 'ok',
52
+ activeSessions: deps.getSessionsSnapshot().length,
53
+ ingestorCounts: counts,
54
+ uptimeSec: Math.floor((Date.now() - deps.startedAt) / 1000),
55
+ });
56
+ });
57
+ // ── /admin/callers ─────────────────────────────────────────────────────
58
+ // Returns caller config metadata only — env values are reduced to key names.
59
+ router.get('/callers', (_req, res) => {
60
+ const config = deps.loadConfig();
61
+ const callersKeysDir = getCallerKeysDir();
62
+ const out = Object.entries(config.callers).map(([alias, caller]) => {
63
+ const keysDirExists = fs.existsSync(path.join(callersKeysDir, alias));
64
+ let fingerprint = null;
65
+ try {
66
+ fingerprint = keysDirExists ? callerFingerprint(alias) : null;
67
+ }
68
+ catch {
69
+ fingerprint = null;
70
+ }
71
+ return {
72
+ alias,
73
+ name: caller.name ?? null,
74
+ connections: caller.connections,
75
+ // Key names ONLY — never the mapping value strings.
76
+ envKeys: Object.keys(caller.env ?? {}),
77
+ fingerprint,
78
+ keysDirExists,
79
+ };
80
+ });
81
+ res.json(out);
82
+ });
83
+ // ── /admin/connections ─────────────────────────────────────────────────
84
+ // listConnectionTemplates already returns names-only — safe as-is.
85
+ router.get('/connections', (_req, res) => {
86
+ res.json(listConnectionTemplates());
87
+ });
88
+ // ── /admin/callers/:alias/connections ──────────────────────────────────
89
+ router.get('/callers/:alias/connections', (req, res) => {
90
+ const config = deps.loadConfig();
91
+ if (!(req.params.alias in config.callers)) {
92
+ res.status(404).json({ error: `Unknown caller: ${req.params.alias}` });
93
+ return;
94
+ }
95
+ const caller = config.callers[req.params.alias];
96
+ const templates = listConnectionTemplates();
97
+ const tplMap = new Map(templates.map((t) => [t.alias, t]));
98
+ const customMap = new Map((config.connectors ?? []).map((c) => [c.alias, c]));
99
+ const out = caller.connections.map((connectionAlias) => {
100
+ const tpl = tplMap.get(connectionAlias);
101
+ const customRoute = customMap.get(connectionAlias);
102
+ const isCustom = !tpl;
103
+ const requiredNames = tpl?.requiredSecrets ?? [];
104
+ const optionalNames = tpl?.optionalSecrets ?? [];
105
+ const requiredSecrets = requiredNames.map((name) => ({
106
+ name,
107
+ present: isSecretSetForCaller(name, req.params.alias, caller.env),
108
+ }));
109
+ const optionalSecrets = optionalNames.map((name) => ({
110
+ name,
111
+ present: isSecretSetForCaller(name, req.params.alias, caller.env),
112
+ }));
113
+ // Resolve listenerConfig so we can identify ListenerConfigField.type === 'secret'
114
+ // fields and strip them from the params projection. Without this, a future
115
+ // template that declares a 'secret' field would silently leak the value via
116
+ // the ...ov.params spread below.
117
+ let listenerConfig;
118
+ if (customRoute) {
119
+ listenerConfig = customRoute.listenerConfig;
120
+ }
121
+ else if (tpl) {
122
+ try {
123
+ listenerConfig = loadConnection(connectionAlias).listenerConfig;
124
+ }
125
+ catch {
126
+ listenerConfig = undefined;
127
+ }
128
+ }
129
+ const secretFieldKeys = new Set((listenerConfig?.fields ?? []).filter((f) => f.type === 'secret').map((f) => f.key));
130
+ // Multi-instance projection — drop anything that could carry secrets.
131
+ const instancesMap = caller.listenerInstances?.[connectionAlias] ?? {};
132
+ const instances = Object.entries(instancesMap).map(([instanceId, ov]) => {
133
+ const safeParams = ov.params !== undefined
134
+ ? Object.fromEntries(Object.entries(ov.params).filter(([k]) => !secretFieldKeys.has(k)))
135
+ : undefined;
136
+ return {
137
+ instanceId,
138
+ enabled: ov.disabled !== true,
139
+ params: {
140
+ ...(ov.intents !== undefined && { intents: ov.intents }),
141
+ ...(ov.eventFilter !== undefined && { eventFilter: ov.eventFilter }),
142
+ ...(ov.guildIds !== undefined && { guildIds: ov.guildIds }),
143
+ ...(ov.channelIds !== undefined && { channelIds: ov.channelIds }),
144
+ ...(ov.userIds !== undefined && { userIds: ov.userIds }),
145
+ ...(ov.bufferSize !== undefined && { bufferSize: ov.bufferSize }),
146
+ ...(ov.intervalMs !== undefined && { intervalMs: ov.intervalMs }),
147
+ ...(ov.disabled !== undefined && { disabled: ov.disabled }),
148
+ // Listener params (board IDs, subreddit names, etc.) are user-set
149
+ // configuration, not secrets. Pass through verbatim — except for any
150
+ // field whose schema marks it as type: 'secret', which is filtered above.
151
+ ...(safeParams !== undefined && safeParams),
152
+ },
153
+ };
154
+ });
155
+ // `enabled`: a connection is enabled by default; explicit disabled
156
+ // override (single-instance) toggles it off.
157
+ const overrideDisabled = caller.ingestorOverrides?.[connectionAlias]?.disabled === true;
158
+ return {
159
+ connectionAlias,
160
+ enabled: !overrideDisabled,
161
+ isCustom,
162
+ requiredSecrets,
163
+ optionalSecrets,
164
+ hasIngestor: tpl?.hasIngestor ?? customMap.get(connectionAlias)?.ingestor !== undefined,
165
+ instances,
166
+ };
167
+ });
168
+ res.json(out);
169
+ });
170
+ // ── /admin/ingestors ───────────────────────────────────────────────────
171
+ router.get('/ingestors', (_req, res) => {
172
+ const statuses = deps.ingestorManager().getAllStatuses();
173
+ // getAllStatuses already augments with callerAlias; connection and
174
+ // instanceId come from base IngestorStatus.
175
+ const out = statuses.map((s) => ({
176
+ callerAlias: s.callerAlias,
177
+ connection: s.connection,
178
+ ...(s.instanceId !== undefined && { instanceId: s.instanceId }),
179
+ type: s.type,
180
+ state: s.state,
181
+ bufferedEvents: s.bufferedEvents,
182
+ totalEventsReceived: s.totalEventsReceived,
183
+ lastEventAt: s.lastEventAt,
184
+ ...(s.error !== undefined && { error: s.error }),
185
+ ...(s.webhookRegistration !== undefined && {
186
+ webhookRegistration: s.webhookRegistration,
187
+ }),
188
+ }));
189
+ res.json(out);
190
+ });
191
+ // ── /admin/sessions ────────────────────────────────────────────────────
192
+ // SessionSnapshot already strips channel + resolvedRoutes — pass it through.
193
+ router.get('/sessions', (_req, res) => {
194
+ res.json(deps.getSessionsSnapshot());
195
+ });
196
+ // ── /admin/secrets ─────────────────────────────────────────────────────
197
+ // Flat join of (caller × connection × secret), with `present` computed via
198
+ // isSecretSetForCaller() — never the actual env value.
199
+ router.get('/secrets', (_req, res) => {
200
+ const config = deps.loadConfig();
201
+ const tplMap = new Map(listConnectionTemplates().map((t) => [t.alias, t]));
202
+ const out = [];
203
+ for (const [callerAlias, caller] of Object.entries(config.callers)) {
204
+ for (const connectionAlias of caller.connections) {
205
+ const tpl = tplMap.get(connectionAlias);
206
+ if (!tpl)
207
+ continue; // custom connector — no template-defined secrets to enumerate
208
+ for (const name of tpl.requiredSecrets) {
209
+ out.push({
210
+ callerAlias,
211
+ connection: connectionAlias,
212
+ name,
213
+ required: true,
214
+ present: isSecretSetForCaller(name, callerAlias, caller.env),
215
+ });
216
+ }
217
+ for (const name of tpl.optionalSecrets) {
218
+ out.push({
219
+ callerAlias,
220
+ connection: connectionAlias,
221
+ name,
222
+ required: false,
223
+ present: isSecretSetForCaller(name, callerAlias, caller.env),
224
+ });
225
+ }
226
+ }
227
+ }
228
+ res.json(out);
229
+ });
230
+ return router;
231
+ }
232
+ //# sourceMappingURL=admin.js.map
@@ -92,6 +92,14 @@ export declare class IngestorManager {
92
92
  * Get status of all ingestors for a caller.
93
93
  */
94
94
  getStatuses(callerAlias: string): IngestorStatus[];
95
+ /**
96
+ * Get status of every ingestor across all callers, augmented with the
97
+ * owning caller alias. Used by the read-only /admin API to render a global
98
+ * dashboard view without iterating per-caller.
99
+ */
100
+ getAllStatuses(): (IngestorStatus & {
101
+ callerAlias: string;
102
+ })[];
95
103
  /**
96
104
  * Subscribe to all events from all ingestors (current and future).
97
105
  * Used by the SSE /events/stream endpoint to fan out events to CLI watchers.
@@ -257,6 +257,19 @@ export class IngestorManager {
257
257
  }
258
258
  return statuses;
259
259
  }
260
+ /**
261
+ * Get status of every ingestor across all callers, augmented with the
262
+ * owning caller alias. Used by the read-only /admin API to render a global
263
+ * dashboard view without iterating per-caller.
264
+ */
265
+ getAllStatuses() {
266
+ const out = [];
267
+ for (const [key, ingestor] of this.ingestors) {
268
+ const { caller } = parseKey(key);
269
+ out.push({ ...ingestor.getStatus(), callerAlias: caller });
270
+ }
271
+ return out;
272
+ }
260
273
  /**
261
274
  * Subscribe to all events from all ingestors (current and future).
262
275
  * Used by the SSE /events/stream endpoint to fan out events to CLI watchers.
@@ -12,6 +12,7 @@
12
12
  * - Maintains an audit log of all operations
13
13
  * - Rate-limits requests per session
14
14
  */
15
+ import express from 'express';
15
16
  import { type RemoteServerConfig, type ResolvedRoute } from '../shared/config.js';
16
17
  import { EncryptedChannel, type PublicKeyBundle } from '../shared/crypto/index.js';
17
18
  import { HandshakeResponder, type HandshakeInit } from '../shared/protocol/index.js';
@@ -43,6 +44,23 @@ export interface PendingHandshake {
43
44
  init: HandshakeInit;
44
45
  createdAt: number;
45
46
  }
47
+ /** Sanitized session projection — never includes channel keys or resolved
48
+ * routes (which carry decrypted secrets). Returned by getSessionsSnapshot()
49
+ * for the read-only /admin API. */
50
+ export interface SessionSnapshot {
51
+ /** First 12 chars of the session ID — enough to disambiguate, doesn't
52
+ * expose enough material to forge or replay encrypted requests. */
53
+ sessionIdShort: string;
54
+ callerAlias: string;
55
+ createdAt: number;
56
+ lastActivity: number;
57
+ requestCount: number;
58
+ windowRequests: number;
59
+ windowStart: number;
60
+ }
61
+ /** Loopback guard — used by /sync/listen, /sync/status, /events/stream, /admin.
62
+ * Hoisted to module scope so the admin router and its tests can reuse it. */
63
+ export declare function requireLoopback(req: express.Request, res: express.Response, next: express.NextFunction): void;
46
64
  export declare function isEndpointAllowed(url: string, patterns: string[]): boolean;
47
65
  export { resolvePlaceholders } from '../shared/config.js';
48
66
  /**
@@ -57,6 +75,13 @@ export declare function cleanupSessions(sessionsMap: Map<string, Pick<Session, '
57
75
  expiredSessions: string[];
58
76
  expiredHandshakes: string[];
59
77
  };
78
+ /**
79
+ * Project the active sessions into a sanitized snapshot for read-only use.
80
+ *
81
+ * Drops `channel` (holds AES keys) and `resolvedRoutes` (carry decrypted
82
+ * secrets). Used by the loopback /admin API; never call res.json(session).
83
+ */
84
+ export declare function getSessionsSnapshot(): SessionSnapshot[];
60
85
  /** A file attachment transmitted as base64 data through the encrypted channel. */
61
86
  export interface FileAttachment {
62
87
  /** Form field name (e.g., "files[0]", "file", "attachment") */
@@ -25,6 +25,7 @@ import { getCallerKeysDir, getServerKeysDir } from '../shared/config.js';
25
25
  import { IngestorManager } from './ingestors/index.js';
26
26
  import { listConnectionTemplates } from '../shared/connections.js';
27
27
  import { isSecretSetForCaller, setCallerSecrets } from '../shared/env-utils.js';
28
+ import { createAdminRouter } from './admin.js';
28
29
  // ── Environment loading ─────────────────────────────────────────────────────
29
30
  /** Load environment from ~/.drawlatch/.env, falling back to cwd .env (legacy). */
30
31
  function loadEnvFile() {
@@ -45,6 +46,40 @@ loadEnvFile();
45
46
  const sessions = new Map();
46
47
  const pendingHandshakes = new Map();
47
48
  let rateLimitPerMinute = 60;
49
+ /** Read package.json version once at module load. */
50
+ const PKG_VERSION = (() => {
51
+ try {
52
+ const raw = fs.readFileSync(new URL('../../package.json', import.meta.url), 'utf8');
53
+ return JSON.parse(raw).version ?? 'unknown';
54
+ }
55
+ catch {
56
+ return 'unknown';
57
+ }
58
+ })();
59
+ /** Resolve the listen port from DRAWLATCH_PORT (env override) or fall back to
60
+ * the configured port. Guards against non-numeric env values that would
61
+ * otherwise be silently coerced to NaN by parseInt. */
62
+ function resolvePort(envValue, fallback) {
63
+ if (envValue === undefined)
64
+ return fallback;
65
+ const parsed = parseInt(envValue, 10);
66
+ if (Number.isNaN(parsed)) {
67
+ console.warn(`[remote] Ignoring DRAWLATCH_PORT="${envValue}" (not a number); falling back to configured port ${fallback}`);
68
+ return fallback;
69
+ }
70
+ return parsed;
71
+ }
72
+ /** Loopback guard — used by /sync/listen, /sync/status, /events/stream, /admin.
73
+ * Hoisted to module scope so the admin router and its tests can reuse it. */
74
+ export function requireLoopback(req, res, next) {
75
+ const addr = req.socket.remoteAddress;
76
+ if (addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1') {
77
+ next();
78
+ }
79
+ else {
80
+ res.status(403).json({ error: 'Forbidden: local access only' });
81
+ }
82
+ }
48
83
  /** Active sync session (at most one at a time). */
49
84
  let activeSyncSession = null;
50
85
  // ── Helpers ────────────────────────────────────────────────────────────────
@@ -148,6 +183,27 @@ export function cleanupSessions(sessionsMap, pendingMap, now = Date.now()) {
148
183
  setInterval(() => {
149
184
  cleanupSessions(sessions, pendingHandshakes);
150
185
  }, 60_000);
186
+ /**
187
+ * Project the active sessions into a sanitized snapshot for read-only use.
188
+ *
189
+ * Drops `channel` (holds AES keys) and `resolvedRoutes` (carry decrypted
190
+ * secrets). Used by the loopback /admin API; never call res.json(session).
191
+ */
192
+ export function getSessionsSnapshot() {
193
+ const out = [];
194
+ for (const [id, s] of sessions) {
195
+ out.push({
196
+ sessionIdShort: id.substring(0, 12),
197
+ callerAlias: s.callerAlias,
198
+ createdAt: s.createdAt,
199
+ lastActivity: s.lastActivity,
200
+ requestCount: s.requestCount,
201
+ windowRequests: s.windowRequests,
202
+ windowStart: s.windowStart,
203
+ });
204
+ }
205
+ return out;
206
+ }
151
207
  // ── Session route invalidation ────────────────────────────────────────────
152
208
  /**
153
209
  * Re-resolve routes for all active sessions belonging to a caller.
@@ -1061,22 +1117,15 @@ export function createApp(options = {}) {
1061
1117
  app.use('/sync', ipRateLimiter(60_000, 10));
1062
1118
  app.use('/webhooks', ipRateLimiter(60_000, 120));
1063
1119
  app.use('/health', ipRateLimiter(60_000, 60));
1064
- }
1065
- // ── Loopback guard for local-only endpoints ──────────────────────────────
1066
- // /sync/listen and /sync/status are intended for local CLI use only.
1067
- // Reject requests from non-loopback addresses to prevent remote abuse.
1068
- function requireLoopback(req, res, next) {
1069
- const addr = req.socket.remoteAddress;
1070
- if (addr === '127.0.0.1' || addr === '::1' || addr === '::ffff:127.0.0.1') {
1071
- next();
1072
- }
1073
- else {
1074
- res.status(403).json({ error: 'Forbidden: local access only' });
1075
- }
1120
+ // /admin is loopback-only but the dashboard polls — give it more headroom than /health
1121
+ app.use('/admin', ipRateLimiter(60_000, 300));
1076
1122
  }
1077
1123
  const config = options.config ?? loadRemoteConfig();
1078
1124
  const ownKeys = options.ownKeys ?? loadKeyBundle(getServerKeysDir());
1079
1125
  const authorizedPeers = options.authorizedPeers ?? loadCallerPeers(config.callers);
1126
+ // Capture for /admin/meta. Resolves the same way main() picks the listen port.
1127
+ const startedAt = Date.now();
1128
+ const port = resolvePort(process.env.DRAWLATCH_PORT, config.port);
1080
1129
  rateLimitPerMinute = config.rateLimitPerMinute;
1081
1130
  // Create or use the provided ingestor manager.
1082
1131
  // When config is loaded from disk (production), pass loadRemoteConfig as the
@@ -1444,6 +1493,16 @@ export function createApp(options = {}) {
1444
1493
  mgr.offEvent(listener);
1445
1494
  });
1446
1495
  });
1496
+ // ── Admin API (loopback-only, read-only) ─────────────────────────────
1497
+ // Powers the drawlatch-ui dashboard. No mutations, no secrets, no CORS.
1498
+ app.use('/admin', requireLoopback, createAdminRouter({
1499
+ getSessionsSnapshot,
1500
+ ingestorManager: () => app.locals.ingestorManager,
1501
+ loadConfig: () => options.config ?? loadRemoteConfig(),
1502
+ version: PKG_VERSION,
1503
+ port,
1504
+ startedAt,
1505
+ }));
1447
1506
  // ── Webhook receiver ─────────────────────────────────────────────────
1448
1507
  // Trello (and potentially other services) send a HEAD request to the
1449
1508
  // callback URL to verify it is reachable before activating the webhook.
@@ -1507,7 +1566,12 @@ export function main() {
1507
1566
  }
1508
1567
  const config = loadRemoteConfig();
1509
1568
  const serverKeysDirPath = getServerKeysDir();
1510
- const requiredKeyFiles = ['signing.key.pem', 'signing.pub.pem', 'exchange.key.pem', 'exchange.pub.pem'];
1569
+ const requiredKeyFiles = [
1570
+ 'signing.key.pem',
1571
+ 'signing.pub.pem',
1572
+ 'exchange.key.pem',
1573
+ 'exchange.pub.pem',
1574
+ ];
1511
1575
  const missingKeyFiles = requiredKeyFiles.filter((f) => !fs.existsSync(path.join(serverKeysDirPath, f)));
1512
1576
  if (missingKeyFiles.length > 0) {
1513
1577
  if (!fs.existsSync(serverKeysDirPath)) {
@@ -1524,7 +1588,7 @@ export function main() {
1524
1588
  console.log('[remote] No callers configured — server will accept sync requests.');
1525
1589
  console.log('[remote] To add callers, run: drawlatch sync');
1526
1590
  }
1527
- const port = process.env.DRAWLATCH_PORT ? parseInt(process.env.DRAWLATCH_PORT, 10) : config.port;
1591
+ const port = resolvePort(process.env.DRAWLATCH_PORT, config.port);
1528
1592
  const host = process.env.DRAWLATCH_HOST ?? config.host;
1529
1593
  const useTunnel = process.env.DRAWLATCH_TUNNEL === '1';
1530
1594
  const app = createApp();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wolpertingerlabs/drawlatch",
3
- "version": "1.0.0-alpha.15.2",
3
+ "version": "1.0.0-alpha.19",
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",