@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.
- package/CONNECTIONS.md +3 -0
- package/README.md +395 -465
- package/bin/drawlatch.js +1018 -49
- package/dist/auth/auth.d.ts +10 -0
- package/dist/auth/auth.js +146 -0
- package/dist/auth/env-writer.d.ts +6 -0
- package/dist/auth/env-writer.js +52 -0
- package/dist/auth/password.d.ts +8 -0
- package/dist/auth/password.js +28 -0
- package/dist/auth/sessions.d.ts +13 -0
- package/dist/auth/sessions.js +85 -0
- package/dist/cli/generate-keys.d.ts +4 -4
- package/dist/cli/generate-keys.js +30 -25
- package/dist/connections/{anthropic.json → ai/anthropic.json} +19 -1
- package/dist/connections/{devin.json → ai/devin.json} +7 -1
- package/dist/connections/{google-ai.json → ai/google-ai.json} +7 -1
- package/dist/connections/{openai.json → ai/openai.json} +7 -1
- package/dist/connections/{openrouter.json → ai/openrouter.json} +7 -1
- package/dist/connections/developer-tools/github.json +138 -0
- package/dist/connections/{hex.json → developer-tools/hex.json} +7 -1
- package/dist/connections/developer-tools/linear.json +75 -0
- package/dist/connections/{lichess.json → gaming/lichess.json} +7 -1
- package/dist/connections/messaging/agentmail.json +21 -0
- package/dist/connections/messaging/discord-bot.json +114 -0
- package/dist/connections/{discord-oauth.json → messaging/discord-oauth.json} +7 -1
- package/dist/connections/messaging/slack.json +75 -0
- package/dist/connections/messaging/telegram.json +66 -0
- package/dist/connections/{google.json → productivity/google.json} +7 -1
- package/dist/connections/productivity/notion.json +75 -0
- package/dist/connections/productivity/stripe.json +74 -0
- package/dist/connections/productivity/trello.json +113 -0
- package/dist/connections/{bluesky.json → social-media/bluesky.json} +41 -0
- package/dist/connections/social-media/mastodon.json +65 -0
- package/dist/connections/social-media/reddit.json +80 -0
- package/dist/connections/{twitch.json → social-media/twitch.json} +40 -0
- package/dist/connections/social-media/x.json +67 -0
- package/dist/mcp/server.js +544 -31
- package/dist/remote/admin-mutations.d.ts +37 -0
- package/dist/remote/admin-mutations.js +251 -0
- package/dist/remote/admin-types.d.ts +149 -0
- package/dist/remote/admin-types.js +11 -0
- package/dist/remote/admin.d.ts +37 -0
- package/dist/remote/admin.js +316 -0
- package/dist/remote/caller-bootstrap.d.ts +71 -0
- package/dist/remote/caller-bootstrap.js +141 -0
- package/dist/remote/ingestors/base-ingestor.d.ts +19 -3
- package/dist/remote/ingestors/base-ingestor.js +27 -6
- package/dist/remote/ingestors/discord/discord-gateway.d.ts +2 -2
- package/dist/remote/ingestors/discord/discord-gateway.js +29 -6
- package/dist/remote/ingestors/e2e/setup.d.ts +69 -0
- package/dist/remote/ingestors/e2e/setup.js +147 -0
- package/dist/remote/ingestors/manager.d.ts +110 -10
- package/dist/remote/ingestors/manager.js +449 -42
- package/dist/remote/ingestors/poll/poll-ingestor.d.ts +4 -2
- package/dist/remote/ingestors/poll/poll-ingestor.js +26 -5
- package/dist/remote/ingestors/registry.d.ts +2 -2
- package/dist/remote/ingestors/registry.js +2 -2
- package/dist/remote/ingestors/slack/socket-mode.d.ts +2 -2
- package/dist/remote/ingestors/slack/socket-mode.js +28 -6
- package/dist/remote/ingestors/types.d.ts +25 -0
- package/dist/remote/ingestors/webhook/base-webhook-ingestor.d.ts +46 -7
- package/dist/remote/ingestors/webhook/base-webhook-ingestor.js +115 -10
- package/dist/remote/ingestors/webhook/github-webhook-ingestor.d.ts +30 -0
- package/dist/remote/ingestors/webhook/github-webhook-ingestor.js +73 -2
- package/dist/remote/ingestors/webhook/lifecycle-types.d.ts +63 -0
- package/dist/remote/ingestors/webhook/lifecycle-types.js +12 -0
- package/dist/remote/ingestors/webhook/stripe-webhook-ingestor.js +2 -2
- package/dist/remote/ingestors/webhook/trello-webhook-ingestor.d.ts +17 -5
- package/dist/remote/ingestors/webhook/trello-webhook-ingestor.js +32 -26
- package/dist/remote/ingestors/webhook/webhook-lifecycle-manager.d.ts +46 -0
- package/dist/remote/ingestors/webhook/webhook-lifecycle-manager.js +261 -0
- package/dist/remote/server.d.ts +25 -33
- package/dist/remote/server.js +681 -195
- package/dist/remote/tool-dispatch.d.ts +84 -0
- package/dist/remote/tool-dispatch.js +910 -0
- package/dist/remote/triggers/rule-engine.d.ts +40 -0
- package/dist/remote/triggers/rule-engine.js +228 -0
- package/dist/remote/triggers/types.d.ts +69 -0
- package/dist/remote/triggers/types.js +10 -0
- package/dist/remote/tunnel-state.d.ts +14 -0
- package/dist/remote/tunnel-state.js +20 -0
- package/dist/remote/tunnel.d.ts +53 -0
- package/dist/remote/tunnel.js +149 -0
- package/dist/shared/config.d.ts +97 -25
- package/dist/shared/config.js +48 -35
- package/dist/shared/connections.d.ts +21 -5
- package/dist/shared/connections.js +56 -14
- package/dist/shared/crypto/index.d.ts +1 -0
- package/dist/shared/crypto/index.js +1 -0
- package/dist/shared/crypto/key-manager.d.ts +67 -0
- package/dist/shared/crypto/key-manager.js +152 -0
- package/dist/shared/env-utils.d.ts +42 -0
- package/dist/shared/env-utils.js +150 -0
- package/dist/shared/listener-config.d.ts +157 -0
- package/dist/shared/listener-config.js +10 -0
- package/dist/shared/migrations.d.ts +40 -0
- package/dist/shared/migrations.js +122 -0
- package/dist/shared/protocol/handshake.js +14 -1
- package/dist/shared/protocol/index.d.ts +2 -0
- package/dist/shared/protocol/index.js +2 -0
- package/dist/shared/protocol/sync-client.d.ts +52 -0
- package/dist/shared/protocol/sync-client.js +99 -0
- package/dist/shared/protocol/sync.d.ts +71 -0
- package/dist/shared/protocol/sync.js +176 -0
- package/frontend/dist/assets/index-D4k5QKBP.css +1 -0
- package/frontend/dist/assets/index-IzwnSOmu.js +267 -0
- package/frontend/dist/index.html +15 -0
- package/package.json +66 -13
- package/dist/connections/discord-bot.json +0 -24
- package/dist/connections/github.json +0 -25
- package/dist/connections/linear.json +0 -29
- package/dist/connections/mastodon.json +0 -25
- package/dist/connections/notion.json +0 -33
- package/dist/connections/reddit.json +0 -28
- package/dist/connections/slack.json +0 -23
- package/dist/connections/stripe.json +0 -25
- package/dist/connections/telegram.json +0 -26
- package/dist/connections/trello.json +0 -25
- package/dist/connections/x.json +0 -27
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* High-level key management API.
|
|
3
|
+
*
|
|
4
|
+
* Provides CRUD operations over the drawlatch key directory structure
|
|
5
|
+
* so that callboard (and other consumers) can manage keys programmatically
|
|
6
|
+
* without touching the filesystem directly.
|
|
7
|
+
*
|
|
8
|
+
* Key layout:
|
|
9
|
+
* keys/callers/<alias>/ — Caller keypairs (one per alias)
|
|
10
|
+
* keys/server/ — Server keypair (single, flat)
|
|
11
|
+
*/
|
|
12
|
+
import fs from 'node:fs';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
import { generateKeyBundle, saveKeyBundle, loadKeyBundle, loadPublicKeys, extractPublicKeys, serializePublicKeys, deserializePublicKeys, fingerprint, } from './keys.js';
|
|
15
|
+
import { getCallerKeysDir, getServerKeysDir, getConfigDir } from '../config.js';
|
|
16
|
+
function resolveConfigDir(opts) {
|
|
17
|
+
if (opts?.configDir)
|
|
18
|
+
return opts.configDir;
|
|
19
|
+
return getConfigDir();
|
|
20
|
+
}
|
|
21
|
+
function callerKeysDir(opts) {
|
|
22
|
+
if (opts?.configDir)
|
|
23
|
+
return path.join(opts.configDir, 'keys', 'callers');
|
|
24
|
+
return getCallerKeysDir();
|
|
25
|
+
}
|
|
26
|
+
function serverKeysDir(opts) {
|
|
27
|
+
if (opts?.configDir)
|
|
28
|
+
return path.join(opts.configDir, 'keys', 'server');
|
|
29
|
+
return getServerKeysDir();
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Create a new caller identity (Ed25519 + X25519 keypairs).
|
|
33
|
+
* Saves under `keys/callers/<alias>/`.
|
|
34
|
+
* Throws if the alias already exists.
|
|
35
|
+
*/
|
|
36
|
+
export function createCaller(alias, opts) {
|
|
37
|
+
const dir = path.join(callerKeysDir(opts), alias);
|
|
38
|
+
if (fs.existsSync(path.join(dir, 'signing.key.pem'))) {
|
|
39
|
+
throw new Error(`Caller "${alias}" already exists at ${dir}`);
|
|
40
|
+
}
|
|
41
|
+
fs.mkdirSync(resolveConfigDir(opts), { recursive: true, mode: 0o700 });
|
|
42
|
+
const bundle = generateKeyBundle();
|
|
43
|
+
saveKeyBundle(bundle, dir);
|
|
44
|
+
const pub = extractPublicKeys(bundle);
|
|
45
|
+
return {
|
|
46
|
+
publicKeys: serializePublicKeys(pub),
|
|
47
|
+
fingerprint: fingerprint(pub),
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Export public keys for a caller identity.
|
|
52
|
+
* Reads from `keys/callers/<alias>/`.
|
|
53
|
+
*/
|
|
54
|
+
export function exportCallerPublicKeys(alias, opts) {
|
|
55
|
+
const dir = path.join(callerKeysDir(opts), alias);
|
|
56
|
+
const pub = loadPublicKeys(dir);
|
|
57
|
+
return serializePublicKeys(pub);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Export public keys for the server.
|
|
61
|
+
* Reads from `keys/server/`.
|
|
62
|
+
*/
|
|
63
|
+
export function exportServerPublicKeys(opts) {
|
|
64
|
+
const dir = serverKeysDir(opts);
|
|
65
|
+
const pub = loadPublicKeys(dir);
|
|
66
|
+
return serializePublicKeys(pub);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Import a caller's public keys. Saves under `keys/callers/<alias>/`.
|
|
70
|
+
* Used by the server to store received caller public keys (e.g., via sync).
|
|
71
|
+
*/
|
|
72
|
+
export function importCallerPublicKeys(alias, keys, opts) {
|
|
73
|
+
const dir = path.join(callerKeysDir(opts), alias);
|
|
74
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
75
|
+
// Validate the keys are parseable before writing
|
|
76
|
+
deserializePublicKeys(keys);
|
|
77
|
+
fs.writeFileSync(path.join(dir, 'signing.pub.pem'), keys.signing, { mode: 0o644 });
|
|
78
|
+
fs.writeFileSync(path.join(dir, 'exchange.pub.pem'), keys.exchange, { mode: 0o644 });
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Save server public keys. Writes to `keys/server/`.
|
|
82
|
+
* Used by callboard to store the remote server's public keys (e.g., via sync).
|
|
83
|
+
*/
|
|
84
|
+
export function saveServerPublicKeys(keys, opts) {
|
|
85
|
+
const dir = serverKeysDir(opts);
|
|
86
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
87
|
+
// Validate the keys are parseable before writing
|
|
88
|
+
deserializePublicKeys(keys);
|
|
89
|
+
fs.writeFileSync(path.join(dir, 'signing.pub.pem'), keys.signing, { mode: 0o644 });
|
|
90
|
+
fs.writeFileSync(path.join(dir, 'exchange.pub.pem'), keys.exchange, { mode: 0o644 });
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* List all caller aliases (scans `keys/callers/`).
|
|
94
|
+
*/
|
|
95
|
+
export function listCallers(opts) {
|
|
96
|
+
const dir = callerKeysDir(opts);
|
|
97
|
+
if (!fs.existsSync(dir))
|
|
98
|
+
return [];
|
|
99
|
+
return fs
|
|
100
|
+
.readdirSync(dir, { withFileTypes: true })
|
|
101
|
+
.filter((d) => d.isDirectory() &&
|
|
102
|
+
(fs.existsSync(path.join(dir, d.name, 'signing.key.pem')) ||
|
|
103
|
+
fs.existsSync(path.join(dir, d.name, 'signing.pub.pem'))))
|
|
104
|
+
.map((d) => d.name);
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Check if a caller identity exists (has at least public keys).
|
|
108
|
+
*/
|
|
109
|
+
export function callerExists(alias, opts) {
|
|
110
|
+
const dir = path.join(callerKeysDir(opts), alias);
|
|
111
|
+
return (fs.existsSync(path.join(dir, 'signing.key.pem')) ||
|
|
112
|
+
fs.existsSync(path.join(dir, 'signing.pub.pem')));
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Check if server keys exist.
|
|
116
|
+
*/
|
|
117
|
+
export function serverExists(opts) {
|
|
118
|
+
const dir = serverKeysDir(opts);
|
|
119
|
+
return (fs.existsSync(path.join(dir, 'signing.key.pem')) ||
|
|
120
|
+
fs.existsSync(path.join(dir, 'signing.pub.pem')));
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Get the fingerprint of a caller's keys.
|
|
124
|
+
*/
|
|
125
|
+
export function callerFingerprint(alias, opts) {
|
|
126
|
+
const dir = path.join(callerKeysDir(opts), alias);
|
|
127
|
+
// Try full bundle first (has private keys), fall back to public-only
|
|
128
|
+
try {
|
|
129
|
+
const bundle = loadKeyBundle(dir);
|
|
130
|
+
return fingerprint(extractPublicKeys(bundle));
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
const pub = loadPublicKeys(dir);
|
|
134
|
+
return fingerprint(pub);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Get the fingerprint of the server's keys.
|
|
139
|
+
*/
|
|
140
|
+
export function serverFingerprint(opts) {
|
|
141
|
+
const dir = serverKeysDir(opts);
|
|
142
|
+
// Try full bundle first (has private keys), fall back to public-only
|
|
143
|
+
try {
|
|
144
|
+
const bundle = loadKeyBundle(dir);
|
|
145
|
+
return fingerprint(extractPublicKeys(bundle));
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
const pub = loadPublicKeys(dir);
|
|
149
|
+
return fingerprint(pub);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
//# sourceMappingURL=key-manager.js.map
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* .env file and secret-status utilities.
|
|
3
|
+
*
|
|
4
|
+
* Shared between the remote server (tool handlers) and callboard (local mode).
|
|
5
|
+
* All functions use getEnvFilePath() from shared/config for path resolution.
|
|
6
|
+
*/
|
|
7
|
+
import { type RemoteServerConfig } from './config.js';
|
|
8
|
+
/** Convert caller alias to env var prefix: "default" → "DEFAULT", "my-agent" → "MY_AGENT" */
|
|
9
|
+
export declare function callerToPrefix(callerAlias: string): string;
|
|
10
|
+
/** Get prefixed env var name: ("default", "GITHUB_TOKEN") → "DEFAULT_GITHUB_TOKEN" */
|
|
11
|
+
export declare function prefixedEnvVar(callerAlias: string, secretName: string): string;
|
|
12
|
+
/** Load all vars from the .env file into a map (does NOT set process.env). */
|
|
13
|
+
export declare function loadEnvFile(): Record<string, string>;
|
|
14
|
+
/** Load .env file into process.env (for startup). */
|
|
15
|
+
export declare function loadEnvIntoProcess(): void;
|
|
16
|
+
/**
|
|
17
|
+
* Write key-value pairs to .env. Empty string = delete.
|
|
18
|
+
* Also sets process.env immediately for in-process use.
|
|
19
|
+
*/
|
|
20
|
+
export declare function setEnvVars(updates: Record<string, string>): void;
|
|
21
|
+
/**
|
|
22
|
+
* Check if a secret is set for a caller.
|
|
23
|
+
* Resolution: caller env mapping → prefixed env var.
|
|
24
|
+
* Bare env var fallback intentionally removed to prevent cross-caller leakage.
|
|
25
|
+
*/
|
|
26
|
+
export declare function isSecretSetForCaller(secretName: string, callerAlias: string, callerEnv?: Record<string, string>): boolean;
|
|
27
|
+
/**
|
|
28
|
+
* Regex for valid secret names: uppercase letters, digits, and underscores;
|
|
29
|
+
* must start with a letter. Prevents injection of system env vars
|
|
30
|
+
* (e.g., PATH, NODE_OPTIONS, LD_PRELOAD).
|
|
31
|
+
*/
|
|
32
|
+
export declare const SECRET_NAME_REGEX: RegExp;
|
|
33
|
+
/**
|
|
34
|
+
* Set secrets for a caller with prefixed env vars.
|
|
35
|
+
* Updates .env file, process.env, and caller's env mapping in config.
|
|
36
|
+
* Returns boolean status per secret name.
|
|
37
|
+
*/
|
|
38
|
+
export declare function setCallerSecrets(secrets: Record<string, string>, callerAlias: string, config: RemoteServerConfig): {
|
|
39
|
+
config: RemoteServerConfig;
|
|
40
|
+
status: Record<string, boolean>;
|
|
41
|
+
};
|
|
42
|
+
//# sourceMappingURL=env-utils.d.ts.map
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* .env file and secret-status utilities.
|
|
3
|
+
*
|
|
4
|
+
* Shared between the remote server (tool handlers) and callboard (local mode).
|
|
5
|
+
* All functions use getEnvFilePath() from shared/config for path resolution.
|
|
6
|
+
*/
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import { getEnvFilePath } from './config.js';
|
|
9
|
+
// ── Caller prefix helpers ────────────────────────────────────────────────────
|
|
10
|
+
/** Convert caller alias to env var prefix: "default" → "DEFAULT", "my-agent" → "MY_AGENT" */
|
|
11
|
+
export function callerToPrefix(callerAlias) {
|
|
12
|
+
return callerAlias.toUpperCase().replace(/-/g, '_');
|
|
13
|
+
}
|
|
14
|
+
/** Get prefixed env var name: ("default", "GITHUB_TOKEN") → "DEFAULT_GITHUB_TOKEN" */
|
|
15
|
+
export function prefixedEnvVar(callerAlias, secretName) {
|
|
16
|
+
return `${callerToPrefix(callerAlias)}_${secretName}`;
|
|
17
|
+
}
|
|
18
|
+
// ── .env file I/O ────────────────────────────────────────────────────────────
|
|
19
|
+
/** Load all vars from the .env file into a map (does NOT set process.env). */
|
|
20
|
+
export function loadEnvFile() {
|
|
21
|
+
const envPath = getEnvFilePath();
|
|
22
|
+
if (!fs.existsSync(envPath))
|
|
23
|
+
return {};
|
|
24
|
+
const content = fs.readFileSync(envPath, 'utf-8');
|
|
25
|
+
const vars = {};
|
|
26
|
+
for (const line of content.split('\n')) {
|
|
27
|
+
const trimmed = line.trim();
|
|
28
|
+
if (!trimmed || trimmed.startsWith('#'))
|
|
29
|
+
continue;
|
|
30
|
+
const eqIdx = trimmed.indexOf('=');
|
|
31
|
+
if (eqIdx === -1)
|
|
32
|
+
continue;
|
|
33
|
+
const key = trimmed.slice(0, eqIdx).trim();
|
|
34
|
+
let value = trimmed.slice(eqIdx + 1).trim();
|
|
35
|
+
// Strip surrounding quotes (single or double)
|
|
36
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
37
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
38
|
+
value = value.slice(1, -1);
|
|
39
|
+
}
|
|
40
|
+
vars[key] = value;
|
|
41
|
+
}
|
|
42
|
+
return vars;
|
|
43
|
+
}
|
|
44
|
+
/** Load .env file into process.env (for startup). */
|
|
45
|
+
export function loadEnvIntoProcess() {
|
|
46
|
+
const vars = loadEnvFile();
|
|
47
|
+
for (const [key, value] of Object.entries(vars)) {
|
|
48
|
+
process.env[key] ??= value;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Write key-value pairs to .env. Empty string = delete.
|
|
53
|
+
* Also sets process.env immediately for in-process use.
|
|
54
|
+
*/
|
|
55
|
+
export function setEnvVars(updates) {
|
|
56
|
+
const envPath = getEnvFilePath();
|
|
57
|
+
let existing = loadEnvFile();
|
|
58
|
+
// Apply updates
|
|
59
|
+
for (const [key, value] of Object.entries(updates)) {
|
|
60
|
+
if (value === '') {
|
|
61
|
+
// Remove key by rebuilding without it
|
|
62
|
+
const { [key]: _removed, ...rest } = existing;
|
|
63
|
+
existing = rest;
|
|
64
|
+
process.env[key] = undefined;
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
existing[key] = value;
|
|
68
|
+
process.env[key] = value;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
// Serialize back to .env format
|
|
72
|
+
const lines = Object.entries(existing).map(([key, value]) => {
|
|
73
|
+
// Quote values that contain spaces, #, or special characters
|
|
74
|
+
if (/[\s#"'\\]/.test(value)) {
|
|
75
|
+
return `${key}="${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
76
|
+
}
|
|
77
|
+
return `${key}=${value}`;
|
|
78
|
+
});
|
|
79
|
+
const dir = envPath.replace(/\/[^/]+$/, '');
|
|
80
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
81
|
+
fs.writeFileSync(envPath, lines.join('\n') + '\n', { mode: 0o600 });
|
|
82
|
+
}
|
|
83
|
+
// ── Secret status checks ─────────────────────────────────────────────────────
|
|
84
|
+
/**
|
|
85
|
+
* Check if a secret is set for a caller.
|
|
86
|
+
* Resolution: caller env mapping → prefixed env var.
|
|
87
|
+
* Bare env var fallback intentionally removed to prevent cross-caller leakage.
|
|
88
|
+
*/
|
|
89
|
+
export function isSecretSetForCaller(secretName, callerAlias, callerEnv) {
|
|
90
|
+
// 1. Check caller's env mapping (e.g., "GITHUB_TOKEN": "${DEFAULT_GITHUB_TOKEN}")
|
|
91
|
+
if (callerEnv && secretName in callerEnv) {
|
|
92
|
+
const mapping = callerEnv[secretName];
|
|
93
|
+
const envMatch = /^\$\{(.+)\}$/.exec(mapping);
|
|
94
|
+
if (envMatch) {
|
|
95
|
+
// It's an env var reference — check if that var is set
|
|
96
|
+
const varName = envMatch[1];
|
|
97
|
+
return process.env[varName] !== undefined && process.env[varName] !== '';
|
|
98
|
+
}
|
|
99
|
+
// It's a literal value
|
|
100
|
+
return mapping !== '';
|
|
101
|
+
}
|
|
102
|
+
// 2. Check prefixed env var (e.g., DEFAULT_GITHUB_TOKEN)
|
|
103
|
+
const prefixed = prefixedEnvVar(callerAlias, secretName);
|
|
104
|
+
return process.env[prefixed] !== undefined && process.env[prefixed] !== '';
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Regex for valid secret names: uppercase letters, digits, and underscores;
|
|
108
|
+
* must start with a letter. Prevents injection of system env vars
|
|
109
|
+
* (e.g., PATH, NODE_OPTIONS, LD_PRELOAD).
|
|
110
|
+
*/
|
|
111
|
+
export const SECRET_NAME_REGEX = /^[A-Z][A-Z0-9_]*$/;
|
|
112
|
+
/**
|
|
113
|
+
* Set secrets for a caller with prefixed env vars.
|
|
114
|
+
* Updates .env file, process.env, and caller's env mapping in config.
|
|
115
|
+
* Returns boolean status per secret name.
|
|
116
|
+
*/
|
|
117
|
+
export function setCallerSecrets(secrets, callerAlias, config) {
|
|
118
|
+
const caller = config.callers[callerAlias];
|
|
119
|
+
if (!caller) {
|
|
120
|
+
throw new Error(`Unknown caller: ${callerAlias}`);
|
|
121
|
+
}
|
|
122
|
+
const envUpdates = {};
|
|
123
|
+
const status = {};
|
|
124
|
+
// Ensure caller has an env mapping
|
|
125
|
+
caller.env ??= {};
|
|
126
|
+
for (const [secretName, value] of Object.entries(secrets)) {
|
|
127
|
+
if (!SECRET_NAME_REGEX.test(secretName)) {
|
|
128
|
+
throw new Error(`Invalid secret name "${secretName}": must match ${SECRET_NAME_REGEX} ` +
|
|
129
|
+
`(uppercase letters, digits, underscores; must start with a letter)`);
|
|
130
|
+
}
|
|
131
|
+
const prefixed = prefixedEnvVar(callerAlias, secretName);
|
|
132
|
+
if (value === '') {
|
|
133
|
+
// Delete: remove from .env and caller env mapping
|
|
134
|
+
envUpdates[prefixed] = '';
|
|
135
|
+
const { [secretName]: _removed, ...rest } = caller.env;
|
|
136
|
+
caller.env = rest;
|
|
137
|
+
status[secretName] = false;
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
// Set: write prefixed env var and update caller env mapping
|
|
141
|
+
envUpdates[prefixed] = value;
|
|
142
|
+
caller.env[secretName] = `\${${prefixed}}`;
|
|
143
|
+
status[secretName] = true;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
// Write to .env file and set process.env
|
|
147
|
+
setEnvVars(envUpdates);
|
|
148
|
+
return { config, status };
|
|
149
|
+
}
|
|
150
|
+
//# sourceMappingURL=env-utils.js.map
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types for connection testing, ingestor testing, and listener
|
|
3
|
+
* configuration schemas.
|
|
4
|
+
*
|
|
5
|
+
* These types are used in connection templates (JSON), the Route interface,
|
|
6
|
+
* ResolvedRoute, and the tool handlers that expose testing and configuration
|
|
7
|
+
* functionality to MCP clients.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Pre-configured test request for verifying connection credentials.
|
|
11
|
+
* Must be a non-destructive, read-only endpoint with zero side effects.
|
|
12
|
+
* Stored in connection templates and carried through to ResolvedRoute.
|
|
13
|
+
*/
|
|
14
|
+
export interface TestConnectionConfig {
|
|
15
|
+
/** HTTP method (default: 'GET'). Should always be non-destructive. */
|
|
16
|
+
method?: string;
|
|
17
|
+
/** URL to test against. May contain ${VAR} placeholders. */
|
|
18
|
+
url: string;
|
|
19
|
+
/** Optional headers beyond the route's auto-injected headers.
|
|
20
|
+
* Values may contain ${VAR} placeholders. */
|
|
21
|
+
headers?: Record<string, string>;
|
|
22
|
+
/** Optional request body (for APIs that require POST for reads, e.g., GraphQL). */
|
|
23
|
+
body?: unknown;
|
|
24
|
+
/** Human-readable description of what this test does (e.g., "Fetches authenticated user"). */
|
|
25
|
+
description?: string;
|
|
26
|
+
/** Expected HTTP status code(s) that indicate success. Default: [200] */
|
|
27
|
+
expectedStatus?: number[];
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Pre-configured test for verifying event listener / ingestor configuration.
|
|
31
|
+
*
|
|
32
|
+
* The semantics vary by strategy:
|
|
33
|
+
* - 'websocket_auth' — Hit an API endpoint that verifies the token is valid for gateway access
|
|
34
|
+
* - 'webhook_verify' — Verify that required webhook secrets are configured
|
|
35
|
+
* - 'poll_once' — Execute a single poll request and verify the response shape
|
|
36
|
+
* - 'http_request' — Execute a custom HTTP request to verify listener setup
|
|
37
|
+
*
|
|
38
|
+
* Set to null in a connection template to explicitly indicate that the
|
|
39
|
+
* listener cannot be independently tested.
|
|
40
|
+
*/
|
|
41
|
+
export interface TestIngestorConfig {
|
|
42
|
+
/** Human-readable description of what this test verifies. */
|
|
43
|
+
description: string;
|
|
44
|
+
/** Strategy for testing. */
|
|
45
|
+
strategy: 'websocket_auth' | 'webhook_verify' | 'poll_once' | 'http_request';
|
|
46
|
+
/** For 'http_request' or 'websocket_auth': the HTTP request to execute. */
|
|
47
|
+
request?: {
|
|
48
|
+
method?: string;
|
|
49
|
+
url: string;
|
|
50
|
+
headers?: Record<string, string>;
|
|
51
|
+
body?: unknown;
|
|
52
|
+
expectedStatus?: number[];
|
|
53
|
+
};
|
|
54
|
+
/** For 'webhook_verify': secret names that must be present and non-empty. */
|
|
55
|
+
requireSecrets?: string[];
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* A single configurable field for an event listener.
|
|
59
|
+
* Provides enough metadata for any UI (web dashboard, CLI, AI agent)
|
|
60
|
+
* to dynamically render the right input control.
|
|
61
|
+
*/
|
|
62
|
+
export interface ListenerConfigField {
|
|
63
|
+
/** Machine-readable key (e.g., "boardId", "eventFilter", "guildIds"). */
|
|
64
|
+
key: string;
|
|
65
|
+
/** Human-readable label for display (e.g., "Trello Board ID"). */
|
|
66
|
+
label: string;
|
|
67
|
+
/** Help text / description shown below the field. */
|
|
68
|
+
description?: string;
|
|
69
|
+
/** Whether this field must be provided before the listener can start. Default: false */
|
|
70
|
+
required?: boolean;
|
|
71
|
+
/**
|
|
72
|
+
* The field type — determines how UIs should render this.
|
|
73
|
+
* - 'text' — single text input
|
|
74
|
+
* - 'number' — numeric input (respects min/max)
|
|
75
|
+
* - 'boolean' — toggle / checkbox
|
|
76
|
+
* - 'select' — single-choice dropdown (uses options)
|
|
77
|
+
* - 'multiselect' — multi-choice checklist (uses options)
|
|
78
|
+
* - 'secret' — masked text input (for sensitive values)
|
|
79
|
+
* - 'text[]' — list of text values (e.g., guild IDs, channel IDs)
|
|
80
|
+
*/
|
|
81
|
+
type: 'text' | 'number' | 'boolean' | 'select' | 'multiselect' | 'secret' | 'text[]';
|
|
82
|
+
/** Default value if not set by the user. */
|
|
83
|
+
default?: string | number | boolean | string[];
|
|
84
|
+
/** For 'select' and 'multiselect': the available options. */
|
|
85
|
+
options?: ListenerConfigOption[];
|
|
86
|
+
/** For 'text': placeholder text. */
|
|
87
|
+
placeholder?: string;
|
|
88
|
+
/** For 'number': minimum allowed value. */
|
|
89
|
+
min?: number;
|
|
90
|
+
/** For 'number': maximum allowed value. */
|
|
91
|
+
max?: number;
|
|
92
|
+
/** For 'text': regex pattern for validation. */
|
|
93
|
+
pattern?: string;
|
|
94
|
+
/**
|
|
95
|
+
* For dynamic options that must be fetched from the API at configure-time.
|
|
96
|
+
* A UI calls `resolve_listener_options` with the connection alias and
|
|
97
|
+
* this param key, and the server executes this request to populate options.
|
|
98
|
+
*/
|
|
99
|
+
dynamicOptions?: {
|
|
100
|
+
/** URL to fetch options from. ${VAR} placeholders resolved. */
|
|
101
|
+
url: string;
|
|
102
|
+
/** HTTP method (default: 'GET'). */
|
|
103
|
+
method?: string;
|
|
104
|
+
/** Request body (for POST requests). */
|
|
105
|
+
body?: unknown;
|
|
106
|
+
/** Dot-path to the array in the response. Omit if top-level array. */
|
|
107
|
+
responsePath?: string;
|
|
108
|
+
/** Field in each item to use as the display label. */
|
|
109
|
+
labelField: string;
|
|
110
|
+
/** Field in each item to use as the value. */
|
|
111
|
+
valueField: string;
|
|
112
|
+
};
|
|
113
|
+
/**
|
|
114
|
+
* Which IngestorOverrides key this maps to for automatic merging.
|
|
115
|
+
* If omitted, the field's `key` is used directly as the override key.
|
|
116
|
+
*/
|
|
117
|
+
overrideKey?: string;
|
|
118
|
+
/**
|
|
119
|
+
* Marks this field as the instance-identifying key for multi-instance support.
|
|
120
|
+
* When true, each unique value of this field creates a separate listener instance
|
|
121
|
+
* (e.g., boardId for Trello, subreddit for Reddit).
|
|
122
|
+
* At most one field per listenerConfig should have instanceKey: true.
|
|
123
|
+
*/
|
|
124
|
+
instanceKey?: boolean;
|
|
125
|
+
/** Group label for organizing fields in UIs (e.g., "Filtering", "Connection", "Advanced"). */
|
|
126
|
+
group?: string;
|
|
127
|
+
}
|
|
128
|
+
/** An option for select/multiselect fields. */
|
|
129
|
+
export interface ListenerConfigOption {
|
|
130
|
+
/** Machine-readable value. */
|
|
131
|
+
value: string | number | boolean;
|
|
132
|
+
/** Human-readable label. */
|
|
133
|
+
label: string;
|
|
134
|
+
/** Optional description for this option. */
|
|
135
|
+
description?: string;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Complete listener configuration schema for a connection.
|
|
139
|
+
* Stored in connection templates alongside the `ingestor` config.
|
|
140
|
+
*/
|
|
141
|
+
export interface ListenerConfigSchema {
|
|
142
|
+
/** Human-readable name (e.g., "Discord Gateway Listener"). */
|
|
143
|
+
name: string;
|
|
144
|
+
/** Description of what this listener does. */
|
|
145
|
+
description?: string;
|
|
146
|
+
/** The configurable fields. */
|
|
147
|
+
fields: ListenerConfigField[];
|
|
148
|
+
/**
|
|
149
|
+
* Whether this listener supports multiple concurrent instances.
|
|
150
|
+
* When true, callers can define `listenerInstances` in their config to spawn
|
|
151
|
+
* N ingestors per connection with different parameter values (e.g., watching
|
|
152
|
+
* multiple Trello boards or multiple Reddit subreddits simultaneously).
|
|
153
|
+
* Default: false
|
|
154
|
+
*/
|
|
155
|
+
supportsMultiInstance?: boolean;
|
|
156
|
+
}
|
|
157
|
+
//# sourceMappingURL=listener-config.d.ts.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared types for connection testing, ingestor testing, and listener
|
|
3
|
+
* configuration schemas.
|
|
4
|
+
*
|
|
5
|
+
* These types are used in connection templates (JSON), the Route interface,
|
|
6
|
+
* ResolvedRoute, and the tool handlers that expose testing and configuration
|
|
7
|
+
* functionality to MCP clients.
|
|
8
|
+
*/
|
|
9
|
+
export {};
|
|
10
|
+
//# sourceMappingURL=listener-config.js.map
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-disk layout migrations — drawlatch owns the evolution of its own config dir.
|
|
3
|
+
*
|
|
4
|
+
* The config-dir contract (stable, documented):
|
|
5
|
+
*
|
|
6
|
+
* $MCP_CONFIG_DIR/ (default: ~/.drawlatch)
|
|
7
|
+
* remote.config.json — RemoteServerConfig (callers, connectors, port…)
|
|
8
|
+
* proxy.config.json — ProxyConfig (local MCP proxy → remote URL)
|
|
9
|
+
* .env — secret values (prefixed per caller)
|
|
10
|
+
* keys/
|
|
11
|
+
* server/ — the daemon's own Ed25519 + X25519 keypair
|
|
12
|
+
* callers/<alias>/ — one keypair per caller alias
|
|
13
|
+
*
|
|
14
|
+
* Historically the key directories used a different layout
|
|
15
|
+
* (`keys/local`, `keys/remote`, `keys/peers/*`). These migrations move any
|
|
16
|
+
* legacy layout to the current one. They were previously implemented in
|
|
17
|
+
* callboard (`migrateKeyDirectories`); drawlatch now owns them so it controls
|
|
18
|
+
* its own layout evolution and callboard can delete the duplicate.
|
|
19
|
+
*
|
|
20
|
+
* All functions are idempotent and safe to call on every startup.
|
|
21
|
+
*/
|
|
22
|
+
/**
|
|
23
|
+
* Migrate the legacy key directory layout to the current callers/server layout.
|
|
24
|
+
*
|
|
25
|
+
* keys/local/<alias>/ → keys/callers/<alias>/
|
|
26
|
+
* keys/remote/ → keys/server/
|
|
27
|
+
* keys/peers/remote-server/ → keys/server/ (public keys only)
|
|
28
|
+
* keys/peers/<alias>/ → keys/callers/<alias>/ (public keys only)
|
|
29
|
+
*
|
|
30
|
+
* Idempotent: only renames when the old dir exists and the new one does not.
|
|
31
|
+
*
|
|
32
|
+
* @param keysDir The keys directory to migrate. Defaults to `getKeysDir()`.
|
|
33
|
+
*/
|
|
34
|
+
export declare function migrateKeyLayout(keysDir?: string): void;
|
|
35
|
+
/**
|
|
36
|
+
* Run every config-dir layout migration for the active `MCP_CONFIG_DIR`.
|
|
37
|
+
* Call once at daemon startup before keys/config are loaded.
|
|
38
|
+
*/
|
|
39
|
+
export declare function migrateConfigDir(): void;
|
|
40
|
+
//# sourceMappingURL=migrations.d.ts.map
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* On-disk layout migrations — drawlatch owns the evolution of its own config dir.
|
|
3
|
+
*
|
|
4
|
+
* The config-dir contract (stable, documented):
|
|
5
|
+
*
|
|
6
|
+
* $MCP_CONFIG_DIR/ (default: ~/.drawlatch)
|
|
7
|
+
* remote.config.json — RemoteServerConfig (callers, connectors, port…)
|
|
8
|
+
* proxy.config.json — ProxyConfig (local MCP proxy → remote URL)
|
|
9
|
+
* .env — secret values (prefixed per caller)
|
|
10
|
+
* keys/
|
|
11
|
+
* server/ — the daemon's own Ed25519 + X25519 keypair
|
|
12
|
+
* callers/<alias>/ — one keypair per caller alias
|
|
13
|
+
*
|
|
14
|
+
* Historically the key directories used a different layout
|
|
15
|
+
* (`keys/local`, `keys/remote`, `keys/peers/*`). These migrations move any
|
|
16
|
+
* legacy layout to the current one. They were previously implemented in
|
|
17
|
+
* callboard (`migrateKeyDirectories`); drawlatch now owns them so it controls
|
|
18
|
+
* its own layout evolution and callboard can delete the duplicate.
|
|
19
|
+
*
|
|
20
|
+
* All functions are idempotent and safe to call on every startup.
|
|
21
|
+
*/
|
|
22
|
+
import { existsSync, mkdirSync, renameSync, readdirSync, rmSync, copyFileSync } from 'node:fs';
|
|
23
|
+
import { join } from 'node:path';
|
|
24
|
+
import { getKeysDir } from './config.js';
|
|
25
|
+
import { createLogger } from './logger.js';
|
|
26
|
+
const log = createLogger('migrations');
|
|
27
|
+
/** Copy *.pub.pem files from src to dest, creating dest if needed. */
|
|
28
|
+
function copyPublicKeys(src, dest) {
|
|
29
|
+
if (!existsSync(src))
|
|
30
|
+
return;
|
|
31
|
+
mkdirSync(dest, { recursive: true, mode: 0o700 });
|
|
32
|
+
const files = readdirSync(src).filter((f) => f.endsWith('.pub.pem'));
|
|
33
|
+
for (const file of files) {
|
|
34
|
+
const destFile = join(dest, file);
|
|
35
|
+
if (!existsSync(destFile)) {
|
|
36
|
+
copyFileSync(join(src, file), destFile);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Migrate the legacy key directory layout to the current callers/server layout.
|
|
42
|
+
*
|
|
43
|
+
* keys/local/<alias>/ → keys/callers/<alias>/
|
|
44
|
+
* keys/remote/ → keys/server/
|
|
45
|
+
* keys/peers/remote-server/ → keys/server/ (public keys only)
|
|
46
|
+
* keys/peers/<alias>/ → keys/callers/<alias>/ (public keys only)
|
|
47
|
+
*
|
|
48
|
+
* Idempotent: only renames when the old dir exists and the new one does not.
|
|
49
|
+
*
|
|
50
|
+
* @param keysDir The keys directory to migrate. Defaults to `getKeysDir()`.
|
|
51
|
+
*/
|
|
52
|
+
export function migrateKeyLayout(keysDir = getKeysDir()) {
|
|
53
|
+
if (!existsSync(keysDir))
|
|
54
|
+
return;
|
|
55
|
+
const oldLocal = join(keysDir, 'local');
|
|
56
|
+
const oldRemote = join(keysDir, 'remote');
|
|
57
|
+
const oldPeers = join(keysDir, 'peers');
|
|
58
|
+
const newCallers = join(keysDir, 'callers');
|
|
59
|
+
const newServer = join(keysDir, 'server');
|
|
60
|
+
try {
|
|
61
|
+
// keys/local/ → keys/callers/
|
|
62
|
+
if (existsSync(oldLocal) && !existsSync(newCallers)) {
|
|
63
|
+
renameSync(oldLocal, newCallers);
|
|
64
|
+
log.info(`Migrated ${oldLocal} -> ${newCallers}`);
|
|
65
|
+
}
|
|
66
|
+
// keys/remote/ → keys/server/
|
|
67
|
+
if (existsSync(oldRemote) && !existsSync(newServer)) {
|
|
68
|
+
renameSync(oldRemote, newServer);
|
|
69
|
+
log.info(`Migrated ${oldRemote} -> ${newServer}`);
|
|
70
|
+
}
|
|
71
|
+
// keys/peers/ — merge individual peer dirs into callers/server
|
|
72
|
+
if (existsSync(oldPeers)) {
|
|
73
|
+
const entries = readdirSync(oldPeers, { withFileTypes: true });
|
|
74
|
+
for (const entry of entries) {
|
|
75
|
+
if (!entry.isDirectory())
|
|
76
|
+
continue;
|
|
77
|
+
if (entry.name === 'remote-server') {
|
|
78
|
+
// peers/remote-server/ → server/ (copy .pub.pem files)
|
|
79
|
+
copyPublicKeys(join(oldPeers, entry.name), newServer);
|
|
80
|
+
log.info(`Migrated ${join(oldPeers, entry.name)} -> ${newServer}`);
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
// peers/<alias>/ → callers/<alias>/ (copy .pub.pem files)
|
|
84
|
+
const targetDir = join(newCallers, entry.name);
|
|
85
|
+
copyPublicKeys(join(oldPeers, entry.name), targetDir);
|
|
86
|
+
log.info(`Migrated ${join(oldPeers, entry.name)} -> ${targetDir}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// Remove the now-merged peers directory
|
|
90
|
+
try {
|
|
91
|
+
rmSync(oldPeers, { recursive: true });
|
|
92
|
+
log.info(`Removed old ${oldPeers} directory`);
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
// Not critical — may still have unexpected files
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// Clean up empty old directories
|
|
99
|
+
for (const dir of [oldLocal, oldRemote]) {
|
|
100
|
+
if (existsSync(dir)) {
|
|
101
|
+
try {
|
|
102
|
+
if (readdirSync(dir).length === 0)
|
|
103
|
+
rmSync(dir);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
// ignore
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
log.warn(`Failed to migrate key directories in ${keysDir}: ${String(err)}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Run every config-dir layout migration for the active `MCP_CONFIG_DIR`.
|
|
117
|
+
* Call once at daemon startup before keys/config are loaded.
|
|
118
|
+
*/
|
|
119
|
+
export function migrateConfigDir() {
|
|
120
|
+
migrateKeyLayout();
|
|
121
|
+
}
|
|
122
|
+
//# sourceMappingURL=migrations.js.map
|