@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,316 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Read-only admin HTTP API for the merged dashboard, served at `/api/admin/*`.
|
|
3
|
+
*
|
|
4
|
+
* Trust boundary: the scrypt password gate (the admin router is mounted behind
|
|
5
|
+
* `requireAuth` in server.ts, wired in Step 2). This replaces the old
|
|
6
|
+
* loopback-only posture, so the dashboard can be exposed on a host bind
|
|
7
|
+
* (DRAWLATCH_HOST=0.0.0.0) while still being password-protected.
|
|
8
|
+
*
|
|
9
|
+
* This file is the type authority for the admin DTOs — see `admin-types.ts`.
|
|
10
|
+
*
|
|
11
|
+
* SECURITY non-negotiables (mirrored in /admin.test.ts):
|
|
12
|
+
* - Never serializes Session.channel (AES keys) or ResolvedRoute.secrets.
|
|
13
|
+
* - Never returns process.env values; only key names. Secret presence is
|
|
14
|
+
* reported via `isSecretSetForCaller()` (booleans only).
|
|
15
|
+
* - Caller `env` mappings (e.g. "GITHUB_TOKEN": "${ACME_GITHUB_TOKEN}") are
|
|
16
|
+
* reduced to key-name lists — the value strings are NOT returned.
|
|
17
|
+
* - No mutations: every endpoint is GET.
|
|
18
|
+
* - No CORS.
|
|
19
|
+
*/
|
|
20
|
+
import express from 'express';
|
|
21
|
+
import fs from 'node:fs';
|
|
22
|
+
import { getCallerKeysDir, getServerKeysDir, getEnvFilePath, getRemoteConfigPath, } from '../shared/config.js';
|
|
23
|
+
import { listConnectionTemplates, loadConnection } from '../shared/connections.js';
|
|
24
|
+
import { isSecretSetForCaller } from '../shared/env-utils.js';
|
|
25
|
+
import { callerFingerprint } from '../shared/crypto/key-manager.js';
|
|
26
|
+
import { getTunnelUrl } from './tunnel-state.js';
|
|
27
|
+
import { mountAdminMutations } from './admin-mutations.js';
|
|
28
|
+
import path from 'node:path';
|
|
29
|
+
export function createAdminRouter(deps) {
|
|
30
|
+
const router = express.Router();
|
|
31
|
+
// ── /admin/meta ────────────────────────────────────────────────────────
|
|
32
|
+
router.get('/meta', (_req, res) => {
|
|
33
|
+
const body = {
|
|
34
|
+
version: deps.version,
|
|
35
|
+
port: deps.port,
|
|
36
|
+
pid: process.pid,
|
|
37
|
+
startedAt: deps.startedAt,
|
|
38
|
+
uptimeSec: Math.floor((Date.now() - deps.startedAt) / 1000),
|
|
39
|
+
configPath: getRemoteConfigPath(),
|
|
40
|
+
callerKeysDir: getCallerKeysDir(),
|
|
41
|
+
serverKeysDir: getServerKeysDir(),
|
|
42
|
+
envFilePath: getEnvFilePath(),
|
|
43
|
+
tunnelUrl: getTunnelUrl(),
|
|
44
|
+
tunnelEnabled: deps.loadConfig().tunnel === true,
|
|
45
|
+
};
|
|
46
|
+
res.json(body);
|
|
47
|
+
});
|
|
48
|
+
// ── /admin/health ──────────────────────────────────────────────────────
|
|
49
|
+
router.get('/health', (_req, res) => {
|
|
50
|
+
const statuses = deps.ingestorManager().getAllStatuses();
|
|
51
|
+
const counts = { connected: 0, error: 0, starting: 0, stopped: 0 };
|
|
52
|
+
for (const s of statuses) {
|
|
53
|
+
if (s.state === 'connected')
|
|
54
|
+
counts.connected++;
|
|
55
|
+
else if (s.state === 'error')
|
|
56
|
+
counts.error++;
|
|
57
|
+
else if (s.state === 'starting' || s.state === 'reconnecting')
|
|
58
|
+
counts.starting++;
|
|
59
|
+
else
|
|
60
|
+
counts.stopped++;
|
|
61
|
+
}
|
|
62
|
+
const body = {
|
|
63
|
+
status: 'ok',
|
|
64
|
+
activeSessions: deps.getSessionsSnapshot().length,
|
|
65
|
+
ingestorCounts: counts,
|
|
66
|
+
uptimeSec: Math.floor((Date.now() - deps.startedAt) / 1000),
|
|
67
|
+
};
|
|
68
|
+
res.json(body);
|
|
69
|
+
});
|
|
70
|
+
// ── /admin/callers ─────────────────────────────────────────────────────
|
|
71
|
+
// Returns caller config metadata only — env values are reduced to key names.
|
|
72
|
+
router.get('/callers', (_req, res) => {
|
|
73
|
+
const config = deps.loadConfig();
|
|
74
|
+
const callersKeysDir = getCallerKeysDir();
|
|
75
|
+
const out = Object.entries(config.callers).map(([alias, caller]) => {
|
|
76
|
+
const keysDirExists = fs.existsSync(path.join(callersKeysDir, alias));
|
|
77
|
+
let fingerprint = null;
|
|
78
|
+
try {
|
|
79
|
+
fingerprint = keysDirExists ? callerFingerprint(alias) : null;
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
fingerprint = null;
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
alias,
|
|
86
|
+
name: caller.name ?? null,
|
|
87
|
+
connections: caller.connections,
|
|
88
|
+
// Key names ONLY — never the mapping value strings.
|
|
89
|
+
envKeys: Object.keys(caller.env ?? {}),
|
|
90
|
+
fingerprint,
|
|
91
|
+
keysDirExists,
|
|
92
|
+
};
|
|
93
|
+
});
|
|
94
|
+
res.json(out);
|
|
95
|
+
});
|
|
96
|
+
// ── /admin/connections ─────────────────────────────────────────────────
|
|
97
|
+
// listConnectionTemplates already returns names-only — safe as-is.
|
|
98
|
+
router.get('/connections', (_req, res) => {
|
|
99
|
+
const out = listConnectionTemplates();
|
|
100
|
+
res.json(out);
|
|
101
|
+
});
|
|
102
|
+
// ── /admin/callers/:alias/connection-status ─────────────────────────────
|
|
103
|
+
// Every connection template + this caller's enabled flag + secret presence
|
|
104
|
+
// (booleans only). The Connections page renders from this single payload.
|
|
105
|
+
router.get('/callers/:alias/connection-status', (req, res) => {
|
|
106
|
+
const config = deps.loadConfig();
|
|
107
|
+
if (!(req.params.alias in config.callers)) {
|
|
108
|
+
res.status(404).json({ error: `Unknown caller: ${req.params.alias}` });
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
const caller = config.callers[req.params.alias];
|
|
112
|
+
const enabled = new Set(caller.connections);
|
|
113
|
+
const out = listConnectionTemplates().map((t) => {
|
|
114
|
+
const requiredSecretsSet = {};
|
|
115
|
+
for (const s of t.requiredSecrets) {
|
|
116
|
+
requiredSecretsSet[s] = isSecretSetForCaller(s, req.params.alias, caller.env);
|
|
117
|
+
}
|
|
118
|
+
const optionalSecretsSet = {};
|
|
119
|
+
for (const s of t.optionalSecrets) {
|
|
120
|
+
optionalSecretsSet[s] = isSecretSetForCaller(s, req.params.alias, caller.env);
|
|
121
|
+
}
|
|
122
|
+
return {
|
|
123
|
+
...t,
|
|
124
|
+
enabled: enabled.has(t.alias),
|
|
125
|
+
requiredSecretsSet,
|
|
126
|
+
optionalSecretsSet,
|
|
127
|
+
};
|
|
128
|
+
});
|
|
129
|
+
res.json(out);
|
|
130
|
+
});
|
|
131
|
+
// ── /admin/callers/:alias/connections ──────────────────────────────────
|
|
132
|
+
router.get('/callers/:alias/connections', (req, res) => {
|
|
133
|
+
const config = deps.loadConfig();
|
|
134
|
+
if (!(req.params.alias in config.callers)) {
|
|
135
|
+
res.status(404).json({ error: `Unknown caller: ${req.params.alias}` });
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const caller = config.callers[req.params.alias];
|
|
139
|
+
const templates = listConnectionTemplates();
|
|
140
|
+
const tplMap = new Map(templates.map((t) => [t.alias, t]));
|
|
141
|
+
const customMap = new Map((config.connectors ?? []).map((c) => [c.alias, c]));
|
|
142
|
+
const out = caller.connections.map((connectionAlias) => {
|
|
143
|
+
const tpl = tplMap.get(connectionAlias);
|
|
144
|
+
const customRoute = customMap.get(connectionAlias);
|
|
145
|
+
const isCustom = !tpl;
|
|
146
|
+
const requiredNames = tpl?.requiredSecrets ?? [];
|
|
147
|
+
const optionalNames = tpl?.optionalSecrets ?? [];
|
|
148
|
+
const requiredSecrets = requiredNames.map((name) => ({
|
|
149
|
+
name,
|
|
150
|
+
present: isSecretSetForCaller(name, req.params.alias, caller.env),
|
|
151
|
+
}));
|
|
152
|
+
const optionalSecrets = optionalNames.map((name) => ({
|
|
153
|
+
name,
|
|
154
|
+
present: isSecretSetForCaller(name, req.params.alias, caller.env),
|
|
155
|
+
}));
|
|
156
|
+
// Resolve listenerConfig so we can identify ListenerConfigField.type === 'secret'
|
|
157
|
+
// fields and strip them from the params projection. Without this, a future
|
|
158
|
+
// template that declares a 'secret' field would silently leak the value via
|
|
159
|
+
// the ...ov.params spread below.
|
|
160
|
+
let listenerConfig;
|
|
161
|
+
if (customRoute) {
|
|
162
|
+
listenerConfig = customRoute.listenerConfig;
|
|
163
|
+
}
|
|
164
|
+
else if (tpl) {
|
|
165
|
+
try {
|
|
166
|
+
listenerConfig = loadConnection(connectionAlias).listenerConfig;
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
169
|
+
listenerConfig = undefined;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const secretFieldKeys = new Set((listenerConfig?.fields ?? []).filter((f) => f.type === 'secret').map((f) => f.key));
|
|
173
|
+
// Multi-instance projection — drop anything that could carry secrets.
|
|
174
|
+
const instancesMap = caller.listenerInstances?.[connectionAlias] ?? {};
|
|
175
|
+
const instances = Object.entries(instancesMap).map(([instanceId, ov]) => {
|
|
176
|
+
const safeParams = ov.params !== undefined
|
|
177
|
+
? Object.fromEntries(Object.entries(ov.params).filter(([k]) => !secretFieldKeys.has(k)))
|
|
178
|
+
: undefined;
|
|
179
|
+
return {
|
|
180
|
+
instanceId,
|
|
181
|
+
enabled: ov.disabled !== true,
|
|
182
|
+
params: {
|
|
183
|
+
...(ov.intents !== undefined && { intents: ov.intents }),
|
|
184
|
+
...(ov.eventFilter !== undefined && { eventFilter: ov.eventFilter }),
|
|
185
|
+
...(ov.guildIds !== undefined && { guildIds: ov.guildIds }),
|
|
186
|
+
...(ov.channelIds !== undefined && { channelIds: ov.channelIds }),
|
|
187
|
+
...(ov.userIds !== undefined && { userIds: ov.userIds }),
|
|
188
|
+
...(ov.bufferSize !== undefined && { bufferSize: ov.bufferSize }),
|
|
189
|
+
...(ov.intervalMs !== undefined && { intervalMs: ov.intervalMs }),
|
|
190
|
+
...(ov.disabled !== undefined && { disabled: ov.disabled }),
|
|
191
|
+
// Listener params (board IDs, subreddit names, etc.) are user-set
|
|
192
|
+
// configuration, not secrets. Pass through verbatim — except for any
|
|
193
|
+
// field whose schema marks it as type: 'secret', which is filtered above.
|
|
194
|
+
...(safeParams !== undefined && safeParams),
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
});
|
|
198
|
+
// `enabled`: a connection is enabled by default; explicit disabled
|
|
199
|
+
// override (single-instance) toggles it off.
|
|
200
|
+
const overrideDisabled = caller.ingestorOverrides?.[connectionAlias]?.disabled === true;
|
|
201
|
+
return {
|
|
202
|
+
connectionAlias,
|
|
203
|
+
enabled: !overrideDisabled,
|
|
204
|
+
isCustom,
|
|
205
|
+
requiredSecrets,
|
|
206
|
+
optionalSecrets,
|
|
207
|
+
hasIngestor: tpl?.hasIngestor ?? customMap.get(connectionAlias)?.ingestor !== undefined,
|
|
208
|
+
instances,
|
|
209
|
+
};
|
|
210
|
+
});
|
|
211
|
+
res.json(out);
|
|
212
|
+
});
|
|
213
|
+
// ── /admin/ingestors ───────────────────────────────────────────────────
|
|
214
|
+
router.get('/ingestors', (_req, res) => {
|
|
215
|
+
const statuses = deps.ingestorManager().getAllStatuses();
|
|
216
|
+
// getAllStatuses already augments with callerAlias; connection and
|
|
217
|
+
// instanceId come from base IngestorStatus.
|
|
218
|
+
const out = statuses.map((s) => ({
|
|
219
|
+
callerAlias: s.callerAlias,
|
|
220
|
+
connection: s.connection,
|
|
221
|
+
...(s.instanceId !== undefined && { instanceId: s.instanceId }),
|
|
222
|
+
type: s.type,
|
|
223
|
+
state: s.state,
|
|
224
|
+
bufferedEvents: s.bufferedEvents,
|
|
225
|
+
totalEventsReceived: s.totalEventsReceived,
|
|
226
|
+
lastEventAt: s.lastEventAt,
|
|
227
|
+
...(s.error !== undefined && { error: s.error }),
|
|
228
|
+
...(s.webhookRegistration !== undefined && {
|
|
229
|
+
webhookRegistration: s.webhookRegistration,
|
|
230
|
+
}),
|
|
231
|
+
}));
|
|
232
|
+
res.json(out);
|
|
233
|
+
});
|
|
234
|
+
// ── /admin/callers/:alias/ingestors ──────────────────────────────────────
|
|
235
|
+
// Per-caller ingestor statuses (the Logs viewer's status cards).
|
|
236
|
+
router.get('/callers/:alias/ingestors', (req, res) => {
|
|
237
|
+
if (!(req.params.alias in deps.loadConfig().callers)) {
|
|
238
|
+
res.status(404).json({ error: `Unknown caller: ${req.params.alias}` });
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const statuses = deps.ingestorManager().getStatuses(req.params.alias);
|
|
242
|
+
const out = statuses.map((s) => ({
|
|
243
|
+
callerAlias: req.params.alias,
|
|
244
|
+
connection: s.connection,
|
|
245
|
+
...(s.instanceId !== undefined && { instanceId: s.instanceId }),
|
|
246
|
+
type: s.type,
|
|
247
|
+
state: s.state,
|
|
248
|
+
bufferedEvents: s.bufferedEvents,
|
|
249
|
+
totalEventsReceived: s.totalEventsReceived,
|
|
250
|
+
lastEventAt: s.lastEventAt,
|
|
251
|
+
...(s.error !== undefined && { error: s.error }),
|
|
252
|
+
...(s.webhookRegistration !== undefined && { webhookRegistration: s.webhookRegistration }),
|
|
253
|
+
}));
|
|
254
|
+
res.json(out);
|
|
255
|
+
});
|
|
256
|
+
// ── /admin/callers/:alias/events ──────────────────────────────────────────
|
|
257
|
+
// Buffered ingestor events for the Logs viewer. These are external-service
|
|
258
|
+
// payloads (Discord messages, webhooks, …), never drawlatch secrets.
|
|
259
|
+
router.get('/callers/:alias/events', (req, res) => {
|
|
260
|
+
if (!(req.params.alias in deps.loadConfig().callers)) {
|
|
261
|
+
res.status(404).json({ error: `Unknown caller: ${req.params.alias}` });
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
const afterRaw = req.query.after_id;
|
|
265
|
+
const afterId = typeof afterRaw === 'string' ? parseInt(afterRaw, 10) : -1;
|
|
266
|
+
const events = deps
|
|
267
|
+
.ingestorManager()
|
|
268
|
+
.getAllEvents(req.params.alias, Number.isNaN(afterId) ? -1 : afterId);
|
|
269
|
+
res.json(events);
|
|
270
|
+
});
|
|
271
|
+
// ── /admin/sessions ────────────────────────────────────────────────────
|
|
272
|
+
// SessionSnapshot already strips channel + resolvedRoutes — pass it through.
|
|
273
|
+
router.get('/sessions', (_req, res) => {
|
|
274
|
+
res.json(deps.getSessionsSnapshot());
|
|
275
|
+
});
|
|
276
|
+
// ── /admin/secrets ─────────────────────────────────────────────────────
|
|
277
|
+
// Flat join of (caller × connection × secret), with `present` computed via
|
|
278
|
+
// isSecretSetForCaller() — never the actual env value.
|
|
279
|
+
router.get('/secrets', (_req, res) => {
|
|
280
|
+
const config = deps.loadConfig();
|
|
281
|
+
const tplMap = new Map(listConnectionTemplates().map((t) => [t.alias, t]));
|
|
282
|
+
const out = [];
|
|
283
|
+
for (const [callerAlias, caller] of Object.entries(config.callers)) {
|
|
284
|
+
for (const connectionAlias of caller.connections) {
|
|
285
|
+
const tpl = tplMap.get(connectionAlias);
|
|
286
|
+
if (!tpl)
|
|
287
|
+
continue; // custom connector — no template-defined secrets to enumerate
|
|
288
|
+
for (const name of tpl.requiredSecrets) {
|
|
289
|
+
out.push({
|
|
290
|
+
callerAlias,
|
|
291
|
+
connection: connectionAlias,
|
|
292
|
+
name,
|
|
293
|
+
required: true,
|
|
294
|
+
present: isSecretSetForCaller(name, callerAlias, caller.env),
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
for (const name of tpl.optionalSecrets) {
|
|
298
|
+
out.push({
|
|
299
|
+
callerAlias,
|
|
300
|
+
connection: connectionAlias,
|
|
301
|
+
name,
|
|
302
|
+
required: false,
|
|
303
|
+
present: isSecretSetForCaller(name, callerAlias, caller.env),
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
res.json(out);
|
|
309
|
+
});
|
|
310
|
+
// ── Mutating endpoints (item A) ──────────────────────────────────────────
|
|
311
|
+
// Connections enable/secrets/test, caller create/delete, and listener/ingestor
|
|
312
|
+
// control. Mounted on the same router (already behind requireAuth in server.ts).
|
|
313
|
+
mountAdminMutations(router, deps);
|
|
314
|
+
return router;
|
|
315
|
+
}
|
|
316
|
+
//# sourceMappingURL=admin.js.map
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Programmatic / non-interactive caller bootstrap (item E).
|
|
3
|
+
*
|
|
4
|
+
* The interactive `drawlatch sync` handshake stays the path for *remote*
|
|
5
|
+
* enrollment (a caller on another machine proving identity over the network).
|
|
6
|
+
* This module is for the zero-friction local case: a co-located client that
|
|
7
|
+
* shares drawlatch's filesystem can provision a caller — complete with a fresh
|
|
8
|
+
* keypair — without any invite-code dance.
|
|
9
|
+
*
|
|
10
|
+
* Two entry points:
|
|
11
|
+
* - `createCallerWithKeys()` — library function (also exposed as the
|
|
12
|
+
* password-gated `POST /api/admin/callers` admin endpoint).
|
|
13
|
+
* - `autoEnroll()` — loopback / shared-fs path: a client proves filesystem
|
|
14
|
+
* access by presenting the one-time token drawlatch writes into the config
|
|
15
|
+
* dir at startup, and gets a caller provisioned with zero interaction.
|
|
16
|
+
*/
|
|
17
|
+
import { type RemoteServerConfig } from '../shared/config.js';
|
|
18
|
+
import type { SerializedPublicKeys } from '../shared/crypto/keys.js';
|
|
19
|
+
/** Valid caller alias: starts with a letter/number, then letters/numbers/-/_. */
|
|
20
|
+
export declare const CALLER_ALIAS_REGEX: RegExp;
|
|
21
|
+
export interface CreateCallerOptions {
|
|
22
|
+
/** Human-readable display name (defaults to the alias). */
|
|
23
|
+
name?: string;
|
|
24
|
+
/** Connection aliases to grant. When omitted, clones the `default` caller's
|
|
25
|
+
* connections so a new caller starts with the same access (common expectation). */
|
|
26
|
+
connections?: string[];
|
|
27
|
+
}
|
|
28
|
+
export interface CreateCallerResult {
|
|
29
|
+
alias: string;
|
|
30
|
+
name: string;
|
|
31
|
+
fingerprint: string;
|
|
32
|
+
/** The caller's freshly-generated public keys (private keys stay on disk). */
|
|
33
|
+
publicKeys: SerializedPublicKeys;
|
|
34
|
+
/** Directory holding the caller's keypair (keys/callers/<alias>/). */
|
|
35
|
+
keysDir: string;
|
|
36
|
+
connections: string[];
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Create a caller WITH a fresh keypair, registered in remote.config.json,
|
|
40
|
+
* without the interactive sync handshake.
|
|
41
|
+
*
|
|
42
|
+
* Throws on invalid alias, or if the caller (config entry or key dir) exists.
|
|
43
|
+
*/
|
|
44
|
+
export declare function createCallerWithKeys(alias: string, opts?: CreateCallerOptions): CreateCallerResult;
|
|
45
|
+
/**
|
|
46
|
+
* Delete a caller: removes its config entry, prefixed env vars, and key dir.
|
|
47
|
+
* The `default` caller cannot be deleted.
|
|
48
|
+
*/
|
|
49
|
+
export declare function deleteCaller(alias: string): void;
|
|
50
|
+
/** Path of the one-time enroll token file inside the config dir. */
|
|
51
|
+
export declare function getEnrollTokenPath(): string;
|
|
52
|
+
/**
|
|
53
|
+
* Write a fresh one-time enroll token into the config dir (mode 0600).
|
|
54
|
+
*
|
|
55
|
+
* Only a process with filesystem access to drawlatch's config dir can read it,
|
|
56
|
+
* which is exactly the proof-of-co-location we want for `autoEnroll()`.
|
|
57
|
+
* Called once at daemon startup; overwrites any stale token.
|
|
58
|
+
*/
|
|
59
|
+
export declare function writeEnrollToken(): string;
|
|
60
|
+
/**
|
|
61
|
+
* Auto-enroll a co-located caller by presenting the one-time enroll token.
|
|
62
|
+
*
|
|
63
|
+
* Verifies the token proves filesystem access to the config dir, then either
|
|
64
|
+
* creates a new caller with keys (if the alias is new) or returns the existing
|
|
65
|
+
* one's metadata (idempotent). The token is single-use: it is rotated on every
|
|
66
|
+
* successful enroll so a leaked token cannot be replayed.
|
|
67
|
+
*/
|
|
68
|
+
export declare function autoEnroll(token: string, alias: string, opts?: CreateCallerOptions): CreateCallerResult;
|
|
69
|
+
/** Re-exported for callers that want to introspect config without re-importing. */
|
|
70
|
+
export type { RemoteServerConfig };
|
|
71
|
+
//# sourceMappingURL=caller-bootstrap.d.ts.map
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Programmatic / non-interactive caller bootstrap (item E).
|
|
3
|
+
*
|
|
4
|
+
* The interactive `drawlatch sync` handshake stays the path for *remote*
|
|
5
|
+
* enrollment (a caller on another machine proving identity over the network).
|
|
6
|
+
* This module is for the zero-friction local case: a co-located client that
|
|
7
|
+
* shares drawlatch's filesystem can provision a caller — complete with a fresh
|
|
8
|
+
* keypair — without any invite-code dance.
|
|
9
|
+
*
|
|
10
|
+
* Two entry points:
|
|
11
|
+
* - `createCallerWithKeys()` — library function (also exposed as the
|
|
12
|
+
* password-gated `POST /api/admin/callers` admin endpoint).
|
|
13
|
+
* - `autoEnroll()` — loopback / shared-fs path: a client proves filesystem
|
|
14
|
+
* access by presenting the one-time token drawlatch writes into the config
|
|
15
|
+
* dir at startup, and gets a caller provisioned with zero interaction.
|
|
16
|
+
*/
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
|
20
|
+
import { loadRemoteConfig, saveRemoteConfig, getConfigDir, getCallerKeysDir, } from '../shared/config.js';
|
|
21
|
+
import { createCaller, callerExists } from '../shared/crypto/key-manager.js';
|
|
22
|
+
import { setEnvVars } from '../shared/env-utils.js';
|
|
23
|
+
import { createLogger } from '../shared/logger.js';
|
|
24
|
+
const log = createLogger('caller-bootstrap');
|
|
25
|
+
/** Valid caller alias: starts with a letter/number, then letters/numbers/-/_. */
|
|
26
|
+
export const CALLER_ALIAS_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
|
|
27
|
+
/**
|
|
28
|
+
* Create a caller WITH a fresh keypair, registered in remote.config.json,
|
|
29
|
+
* without the interactive sync handshake.
|
|
30
|
+
*
|
|
31
|
+
* Throws on invalid alias, or if the caller (config entry or key dir) exists.
|
|
32
|
+
*/
|
|
33
|
+
export function createCallerWithKeys(alias, opts = {}) {
|
|
34
|
+
if (!CALLER_ALIAS_REGEX.test(alias)) {
|
|
35
|
+
throw new Error('Invalid alias: must start with a letter or number and contain only ' +
|
|
36
|
+
'letters, numbers, hyphens, and underscores');
|
|
37
|
+
}
|
|
38
|
+
const config = loadRemoteConfig();
|
|
39
|
+
if (alias in config.callers || callerExists(alias)) {
|
|
40
|
+
throw new Error(`Caller "${alias}" already exists`);
|
|
41
|
+
}
|
|
42
|
+
// Generate the caller's keypair under keys/callers/<alias>/.
|
|
43
|
+
const { publicKeys, fingerprint } = createCaller(alias);
|
|
44
|
+
const name = opts.name ?? alias;
|
|
45
|
+
const defaultCaller = config.callers.default;
|
|
46
|
+
const connections = opts.connections ?? [...(defaultCaller?.connections ?? [])];
|
|
47
|
+
config.callers[alias] = { name, connections };
|
|
48
|
+
saveRemoteConfig(config);
|
|
49
|
+
log.info(`Created caller "${alias}" with keypair (${connections.length} connection(s), fingerprint ${fingerprint})`);
|
|
50
|
+
return {
|
|
51
|
+
alias,
|
|
52
|
+
name,
|
|
53
|
+
fingerprint,
|
|
54
|
+
publicKeys,
|
|
55
|
+
keysDir: path.join(getCallerKeysDir(), alias),
|
|
56
|
+
connections,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Delete a caller: removes its config entry, prefixed env vars, and key dir.
|
|
61
|
+
* The `default` caller cannot be deleted.
|
|
62
|
+
*/
|
|
63
|
+
export function deleteCaller(alias) {
|
|
64
|
+
if (alias === 'default') {
|
|
65
|
+
throw new Error('Cannot delete the "default" caller');
|
|
66
|
+
}
|
|
67
|
+
const config = loadRemoteConfig();
|
|
68
|
+
if (!(alias in config.callers)) {
|
|
69
|
+
throw new Error(`Caller "${alias}" not found`);
|
|
70
|
+
}
|
|
71
|
+
// Clean up prefixed env vars referenced by this caller's env mapping.
|
|
72
|
+
const caller = config.callers[alias];
|
|
73
|
+
if (caller.env) {
|
|
74
|
+
const envUpdates = {};
|
|
75
|
+
for (const mapping of Object.values(caller.env)) {
|
|
76
|
+
const envMatch = /^\$\{(.+)\}$/.exec(mapping);
|
|
77
|
+
if (envMatch)
|
|
78
|
+
envUpdates[envMatch[1]] = ''; // empty string = delete
|
|
79
|
+
}
|
|
80
|
+
if (Object.keys(envUpdates).length > 0)
|
|
81
|
+
setEnvVars(envUpdates);
|
|
82
|
+
}
|
|
83
|
+
const { [alias]: _removed, ...remainingCallers } = config.callers;
|
|
84
|
+
config.callers = remainingCallers;
|
|
85
|
+
saveRemoteConfig(config);
|
|
86
|
+
// Remove the caller's key directory.
|
|
87
|
+
const keysDir = path.join(getCallerKeysDir(), alias);
|
|
88
|
+
if (fs.existsSync(keysDir)) {
|
|
89
|
+
fs.rmSync(keysDir, { recursive: true, force: true });
|
|
90
|
+
}
|
|
91
|
+
log.info(`Deleted caller "${alias}"`);
|
|
92
|
+
}
|
|
93
|
+
// ── Loopback / shared-fs auto-enroll ───────────────────────────────────────
|
|
94
|
+
/** Path of the one-time enroll token file inside the config dir. */
|
|
95
|
+
export function getEnrollTokenPath() {
|
|
96
|
+
return path.join(getConfigDir(), 'enroll.token');
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Write a fresh one-time enroll token into the config dir (mode 0600).
|
|
100
|
+
*
|
|
101
|
+
* Only a process with filesystem access to drawlatch's config dir can read it,
|
|
102
|
+
* which is exactly the proof-of-co-location we want for `autoEnroll()`.
|
|
103
|
+
* Called once at daemon startup; overwrites any stale token.
|
|
104
|
+
*/
|
|
105
|
+
export function writeEnrollToken() {
|
|
106
|
+
const token = randomBytes(32).toString('hex');
|
|
107
|
+
const tokenPath = getEnrollTokenPath();
|
|
108
|
+
fs.mkdirSync(getConfigDir(), { recursive: true, mode: 0o700 });
|
|
109
|
+
fs.writeFileSync(tokenPath, token, { mode: 0o600 });
|
|
110
|
+
return token;
|
|
111
|
+
}
|
|
112
|
+
/** Constant-time compare of the presented token against the on-disk token. */
|
|
113
|
+
function tokenMatches(presented) {
|
|
114
|
+
const tokenPath = getEnrollTokenPath();
|
|
115
|
+
if (!fs.existsSync(tokenPath))
|
|
116
|
+
return false;
|
|
117
|
+
const onDisk = fs.readFileSync(tokenPath, 'utf8').trim();
|
|
118
|
+
const a = Buffer.from(onDisk);
|
|
119
|
+
const b = Buffer.from(presented);
|
|
120
|
+
if (a.length !== b.length)
|
|
121
|
+
return false;
|
|
122
|
+
return timingSafeEqual(a, b);
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Auto-enroll a co-located caller by presenting the one-time enroll token.
|
|
126
|
+
*
|
|
127
|
+
* Verifies the token proves filesystem access to the config dir, then either
|
|
128
|
+
* creates a new caller with keys (if the alias is new) or returns the existing
|
|
129
|
+
* one's metadata (idempotent). The token is single-use: it is rotated on every
|
|
130
|
+
* successful enroll so a leaked token cannot be replayed.
|
|
131
|
+
*/
|
|
132
|
+
export function autoEnroll(token, alias, opts = {}) {
|
|
133
|
+
if (!tokenMatches(token)) {
|
|
134
|
+
throw new Error('Invalid or expired enroll token');
|
|
135
|
+
}
|
|
136
|
+
const result = createCallerWithKeys(alias, opts);
|
|
137
|
+
// Rotate the token so it cannot be replayed.
|
|
138
|
+
writeEnrollToken();
|
|
139
|
+
return result;
|
|
140
|
+
}
|
|
141
|
+
//# sourceMappingURL=caller-bootstrap.js.map
|
|
@@ -18,11 +18,20 @@ export declare abstract class BaseIngestor extends EventEmitter {
|
|
|
18
18
|
protected readonly ingestorType: 'websocket' | 'webhook' | 'poll';
|
|
19
19
|
/** Resolved secrets for the parent connection. */
|
|
20
20
|
protected readonly secrets: Record<string, string>;
|
|
21
|
+
/** Optional instance identifier for multi-instance support.
|
|
22
|
+
* When set, this ingestor is one of N instances for the same connection
|
|
23
|
+
* (e.g., watching different Trello boards or Reddit subreddits). */
|
|
24
|
+
protected readonly instanceId?: string | undefined;
|
|
21
25
|
protected state: IngestorState;
|
|
22
26
|
protected buffer: RingBuffer<IngestedEvent>;
|
|
23
27
|
protected counter: number;
|
|
24
28
|
protected lastEventAt: string | null;
|
|
25
29
|
protected errorMessage?: string;
|
|
30
|
+
/** Caller alias that owns this ingestor. Set by IngestorManager after creation. */
|
|
31
|
+
callerAlias: string;
|
|
32
|
+
/** Per-instance epoch for event ID generation.
|
|
33
|
+
* Each instance claims a unique epoch so IDs never collide across restarts. */
|
|
34
|
+
private readonly bootEpoch;
|
|
26
35
|
/** Recently seen idempotency keys for deduplication. */
|
|
27
36
|
private readonly seenKeys;
|
|
28
37
|
constructor(
|
|
@@ -33,11 +42,18 @@ export declare abstract class BaseIngestor extends EventEmitter {
|
|
|
33
42
|
/** Resolved secrets for the parent connection. */
|
|
34
43
|
secrets: Record<string, string>,
|
|
35
44
|
/** Buffer capacity (defaults to DEFAULT_BUFFER_SIZE). */
|
|
36
|
-
bufferSize?: number
|
|
45
|
+
bufferSize?: number,
|
|
46
|
+
/** Optional instance identifier for multi-instance support.
|
|
47
|
+
* When set, this ingestor is one of N instances for the same connection
|
|
48
|
+
* (e.g., watching different Trello boards or Reddit subreddits). */
|
|
49
|
+
instanceId?: string | undefined);
|
|
37
50
|
/** Start the ingestor (connect WebSocket, begin polling, etc.). */
|
|
38
51
|
abstract start(): Promise<void>;
|
|
39
|
-
/** Stop the ingestor cleanly (close connections, clear timers).
|
|
40
|
-
|
|
52
|
+
/** Stop the ingestor cleanly (close connections, clear timers).
|
|
53
|
+
* @param permanent When true, indicates a permanent shutdown (server exit
|
|
54
|
+
* or instance deletion). Webhook ingestors use this to
|
|
55
|
+
* unregister webhooks from the external service. */
|
|
56
|
+
abstract stop(permanent?: boolean): Promise<void>;
|
|
41
57
|
/**
|
|
42
58
|
* Push a new event into the ring buffer.
|
|
43
59
|
* Called by subclasses when they receive data from an external service.
|
|
@@ -18,25 +18,35 @@ const log = createLogger('ingestor');
|
|
|
18
18
|
* When exceeded, the oldest half is pruned. */
|
|
19
19
|
const MAX_SEEN_KEYS = 2000;
|
|
20
20
|
/**
|
|
21
|
-
* Epoch-based event IDs that are monotonically increasing across
|
|
21
|
+
* Epoch-based event IDs that are monotonically increasing across restarts.
|
|
22
22
|
* Format: `bootEpochSeconds * 1_000_000 + counter`.
|
|
23
23
|
*
|
|
24
24
|
* Uses seconds (not milliseconds) so the product stays within Number.MAX_SAFE_INTEGER.
|
|
25
25
|
* This prevents clients with a stale `after_id` cursor from missing events
|
|
26
26
|
* after a reboot — new IDs will always be higher than pre-reboot IDs
|
|
27
|
-
* (assuming <1M events per
|
|
27
|
+
* (assuming <1M events per instance and >1s between restarts).
|
|
28
|
+
*
|
|
29
|
+
* Uses a monotonically increasing module-level epoch so that in-process restarts
|
|
30
|
+
* (e.g., LocalProxy.reinitialize()) also produce strictly increasing IDs.
|
|
31
|
+
* Each new BaseIngestor instance claims the next available epoch.
|
|
28
32
|
*/
|
|
29
|
-
|
|
33
|
+
let nextEpoch = Math.floor(Date.now() / 1000);
|
|
30
34
|
const ID_MULTIPLIER = 1_000_000;
|
|
31
35
|
export class BaseIngestor extends EventEmitter {
|
|
32
36
|
connectionAlias;
|
|
33
37
|
ingestorType;
|
|
34
38
|
secrets;
|
|
39
|
+
instanceId;
|
|
35
40
|
state = 'stopped';
|
|
36
41
|
buffer;
|
|
37
42
|
counter = 0;
|
|
38
43
|
lastEventAt = null;
|
|
39
44
|
errorMessage;
|
|
45
|
+
/** Caller alias that owns this ingestor. Set by IngestorManager after creation. */
|
|
46
|
+
callerAlias = 'unknown';
|
|
47
|
+
/** Per-instance epoch for event ID generation.
|
|
48
|
+
* Each instance claims a unique epoch so IDs never collide across restarts. */
|
|
49
|
+
bootEpoch;
|
|
40
50
|
/** Recently seen idempotency keys for deduplication. */
|
|
41
51
|
seenKeys = new Set();
|
|
42
52
|
constructor(
|
|
@@ -47,12 +57,20 @@ export class BaseIngestor extends EventEmitter {
|
|
|
47
57
|
/** Resolved secrets for the parent connection. */
|
|
48
58
|
secrets,
|
|
49
59
|
/** Buffer capacity (defaults to DEFAULT_BUFFER_SIZE). */
|
|
50
|
-
bufferSize = DEFAULT_BUFFER_SIZE
|
|
60
|
+
bufferSize = DEFAULT_BUFFER_SIZE,
|
|
61
|
+
/** Optional instance identifier for multi-instance support.
|
|
62
|
+
* When set, this ingestor is one of N instances for the same connection
|
|
63
|
+
* (e.g., watching different Trello boards or Reddit subreddits). */
|
|
64
|
+
instanceId) {
|
|
51
65
|
super();
|
|
52
66
|
this.connectionAlias = connectionAlias;
|
|
53
67
|
this.ingestorType = ingestorType;
|
|
54
68
|
this.secrets = secrets;
|
|
69
|
+
this.instanceId = instanceId;
|
|
55
70
|
this.buffer = new RingBuffer(bufferSize);
|
|
71
|
+
// Claim a unique epoch for this instance — ensures event IDs are strictly
|
|
72
|
+
// increasing even when ingestors are restarted in-process.
|
|
73
|
+
this.bootEpoch = nextEpoch++;
|
|
56
74
|
}
|
|
57
75
|
/**
|
|
58
76
|
* Push a new event into the ring buffer.
|
|
@@ -71,14 +89,16 @@ export class BaseIngestor extends EventEmitter {
|
|
|
71
89
|
return;
|
|
72
90
|
}
|
|
73
91
|
const now = new Date();
|
|
74
|
-
const id =
|
|
92
|
+
const id = this.bootEpoch * ID_MULTIPLIER + this.counter++;
|
|
75
93
|
const key = idempotencyKey ?? `${this.connectionAlias}:${crypto.randomUUID()}`;
|
|
76
94
|
const event = {
|
|
77
95
|
id,
|
|
78
96
|
idempotencyKey: key,
|
|
79
97
|
receivedAt: now.toISOString(),
|
|
80
98
|
receivedAtMs: now.getTime(),
|
|
99
|
+
callerAlias: this.callerAlias,
|
|
81
100
|
source: this.connectionAlias,
|
|
101
|
+
...(this.instanceId !== undefined && { instanceId: this.instanceId }),
|
|
82
102
|
eventType,
|
|
83
103
|
data,
|
|
84
104
|
};
|
|
@@ -90,7 +110,7 @@ export class BaseIngestor extends EventEmitter {
|
|
|
90
110
|
this.pruneSeenKeys();
|
|
91
111
|
}
|
|
92
112
|
log.info(`${this.connectionAlias} event #${event.id}: ${eventType}`);
|
|
93
|
-
log.debug(`${this.connectionAlias} event #${event.id} payload:`, JSON.stringify(data
|
|
113
|
+
log.debug(`${this.connectionAlias} event #${event.id} payload size:`, JSON.stringify(data).length);
|
|
94
114
|
this.emit('event', event);
|
|
95
115
|
}
|
|
96
116
|
/**
|
|
@@ -120,6 +140,7 @@ export class BaseIngestor extends EventEmitter {
|
|
|
120
140
|
getStatus() {
|
|
121
141
|
return {
|
|
122
142
|
connection: this.connectionAlias,
|
|
143
|
+
...(this.instanceId !== undefined && { instanceId: this.instanceId }),
|
|
123
144
|
type: this.ingestorType,
|
|
124
145
|
state: this.state,
|
|
125
146
|
bufferedEvents: this.buffer.size,
|