@stage-labs/metro 0.1.0-beta.97 → 0.1.0-beta.99
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/package.json +1 -1
- package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
- package/runtime/node_modules/@metro-labs/core/src/lines.ts +5 -0
- package/runtime/node_modules/@metro-labs/core/src/station-names.ts +1 -0
- package/runtime/node_modules/@metro-labs/daemon/package.json +1 -0
- package/runtime/node_modules/@metro-labs/daemon/src/agents/accounts-api.ts +35 -6
- package/runtime/node_modules/@metro-labs/daemon/src/agents/bundle.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agents/file-admin.ts +17 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agents/files.ts +1 -1
- package/runtime/node_modules/@metro-labs/daemon/src/agents/map.ts +7 -0
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/accounts.ts +16 -4
- package/runtime/node_modules/@metro-labs/daemon/src/routes/body.ts +24 -0
- package/runtime/node_modules/@metro-labs/daemon/src/routes/http.ts +5 -23
- package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/routes/threema-callback.ts +103 -0
- package/runtime/node_modules/@metro-labs/daemon/src/stations/attach.ts +64 -0
- package/runtime/node_modules/@metro-labs/daemon/src/stations/materialize.ts +14 -1
- package/runtime/node_modules/@metro-labs/daemon/src/stations/registry.ts +2 -0
- package/runtime/node_modules/@metro-labs/daemon/src/stations/threema-callbacks.ts +32 -0
- package/runtime/node_modules/@metro-labs/threema/package.json +26 -0
- package/runtime/node_modules/@metro-labs/threema/src/accounts.ts +100 -0
- package/runtime/node_modules/@metro-labs/threema/src/actions.ts +219 -0
- package/runtime/node_modules/@metro-labs/threema/src/api.ts +130 -0
- package/runtime/node_modules/@metro-labs/threema/src/crypto.ts +132 -0
- package/runtime/node_modules/@metro-labs/threema/src/format.ts +123 -0
- package/runtime/node_modules/@metro-labs/threema/src/ids.ts +21 -0
- package/runtime/node_modules/@metro-labs/threema/src/index.ts +29 -0
- package/runtime/node_modules/@metro-labs/threema/src/station.ts +10 -0
- package/runtime/node_modules/@metro-labs/threema/src/verify.ts +117 -0
- package/runtime/runtime.json +1 -1
- package/runtime/stations.json +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stage-labs/metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.99",
|
|
4
4
|
"description": "The metro command line. Sign in once per machine, then hand your MCP connector list to Claude Code without the credentials touching disk, argv or shell history.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.99",
|
|
4
4
|
"description": "Metro connectors for Claude Code. Runs beside the metro daemon on this machine: each connector the agent holds is one MCP server relayed through the daemon, the daemon keeps that list current on its own, and /reload-plugins picks up a new one without leaving your session. No vendor credential ever sits in a config file.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Bonustrack",
|
|
@@ -25,6 +25,8 @@ function parseAccountScoped(
|
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
const isSnowflake = (s: string): boolean => /^\d+$/.test(s);
|
|
28
|
+
const isThreemaId = (s: string): boolean =>
|
|
29
|
+
/^(?:[A-Z0-9]{8}|\*[A-Z0-9]{7})$/i.test(s);
|
|
28
30
|
const isSignedInt = (s: string): boolean => /^-?\d+$/.test(s);
|
|
29
31
|
|
|
30
32
|
function splitTelegramAccount(path: string[]): { accountId: string; rest: string[] } {
|
|
@@ -79,4 +81,7 @@ export const Line = {
|
|
|
79
81
|
parseAccountScoped(line, 'discord-bot', isSnowflake),
|
|
80
82
|
|
|
81
83
|
parseTelegram: (line: Line | string) => parseTelegramLine(line),
|
|
84
|
+
|
|
85
|
+
parseThreema: (line: Line | string) =>
|
|
86
|
+
parseAccountScoped(line, 'threema', isThreemaId),
|
|
82
87
|
};
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
"@metro-labs/discord-bot": "workspace:*",
|
|
29
29
|
"@metro-labs/telegram": "workspace:*",
|
|
30
30
|
"@metro-labs/telegram-bot": "workspace:*",
|
|
31
|
+
"@metro-labs/threema": "workspace:*",
|
|
31
32
|
"@metro-labs/webhook": "workspace:*",
|
|
32
33
|
"@metro-labs/whatsapp": "workspace:*",
|
|
33
34
|
"@metro-labs/xmtp": "workspace:*",
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
import type { StationName } from '@metro-labs/core/station-names';
|
|
19
19
|
import {
|
|
20
20
|
ATTACHABLE_STATIONS,
|
|
21
|
+
attachInputOf,
|
|
21
22
|
isAttachStation,
|
|
22
23
|
type AttachInput,
|
|
23
24
|
type OneTimeSecret,
|
|
@@ -74,6 +75,13 @@ export interface AccountApiDeps {
|
|
|
74
75
|
allowlist: string[],
|
|
75
76
|
) => Promise<string[]>;
|
|
76
77
|
recentSenders: (station: StationName, accountId: string) => RecentSender[];
|
|
78
|
+
setAccountEnabled: (
|
|
79
|
+
subject: string,
|
|
80
|
+
agentId: string,
|
|
81
|
+
station: StationName,
|
|
82
|
+
accountId: string,
|
|
83
|
+
enabled: boolean,
|
|
84
|
+
) => Promise<boolean>;
|
|
77
85
|
}
|
|
78
86
|
|
|
79
87
|
export type AccountRoute =
|
|
@@ -82,7 +90,8 @@ export type AccountRoute =
|
|
|
82
90
|
| { kind: 'step'; attachId: string }
|
|
83
91
|
| { kind: 'account'; station: StationName; accountId: string }
|
|
84
92
|
| { kind: 'allowlist'; station: StationName; accountId: string }
|
|
85
|
-
| { kind: 'senders'; station: StationName; accountId: string }
|
|
93
|
+
| { kind: 'senders'; station: StationName; accountId: string }
|
|
94
|
+
| { kind: 'enabled'; station: StationName; accountId: string };
|
|
86
95
|
|
|
87
96
|
export const ATTACHABLE: string[] = [
|
|
88
97
|
...ATTACHABLE_STATIONS,
|
|
@@ -96,6 +105,7 @@ const ROUTE_METHODS: Record<AccountRoute['kind'], string[]> = {
|
|
|
96
105
|
account: ['DELETE'],
|
|
97
106
|
allowlist: ['PUT'],
|
|
98
107
|
senders: ['GET'],
|
|
108
|
+
enabled: ['PUT'],
|
|
99
109
|
};
|
|
100
110
|
|
|
101
111
|
function twoSegmentRoute(head: string, tail: string): AccountRoute | null {
|
|
@@ -121,7 +131,7 @@ export function accountRoute(rest: string[]): AccountRoute | null {
|
|
|
121
131
|
}
|
|
122
132
|
|
|
123
133
|
function accountSubRoute(head: string, tail: string, sub: string | undefined): AccountRoute | null {
|
|
124
|
-
if (!isStationName(head) || (sub !== 'allowlist' && sub !== 'senders')) return null;
|
|
134
|
+
if (!isStationName(head) || (sub !== 'allowlist' && sub !== 'senders' && sub !== 'enabled')) return null;
|
|
125
135
|
const accountId = parseAccountId(tail);
|
|
126
136
|
return accountId === null ? null : { kind: sub, station: head, accountId };
|
|
127
137
|
}
|
|
@@ -210,10 +220,7 @@ async function handleStart(
|
|
|
210
220
|
}
|
|
211
221
|
if (!isAttachStation(station))
|
|
212
222
|
throw new ApiError(`station must be one of ${ATTACHABLE.join(', ')}`, 400);
|
|
213
|
-
const prepared = await deps.prepareAccount(
|
|
214
|
-
station,
|
|
215
|
-
token: bodyField(body, 'token'),
|
|
216
|
-
});
|
|
223
|
+
const prepared = await deps.prepareAccount(attachInputOf(station, body));
|
|
217
224
|
const ref = await storeAccount(deps, session, agentId, station, prepared);
|
|
218
225
|
log.info(
|
|
219
226
|
{ agentId: ref.agentId, station, account: ref.accountId },
|
|
@@ -294,6 +301,27 @@ async function handleAllowlist(
|
|
|
294
301
|
});
|
|
295
302
|
}
|
|
296
303
|
|
|
304
|
+
async function handleEnabled(
|
|
305
|
+
req: IncomingMessage,
|
|
306
|
+
res: ServerResponse,
|
|
307
|
+
deps: AccountApiDeps,
|
|
308
|
+
session: ApiSession,
|
|
309
|
+
agentId: string,
|
|
310
|
+
target: { station: StationName; accountId: string },
|
|
311
|
+
): Promise<void> {
|
|
312
|
+
const wanted = bodyField(await readJsonBody(req), 'enabled');
|
|
313
|
+
if (typeof wanted !== 'boolean') throw new ApiError('enabled must be true or false', 400);
|
|
314
|
+
const enabled = await deps.setAccountEnabled(session.subject, agentId, target.station, target.accountId, wanted);
|
|
315
|
+
log.info({ agentId, station: target.station, account: target.accountId, enabled }, 'account-api: account enabled flag set');
|
|
316
|
+
sendJson(req, res, 200, {
|
|
317
|
+
agentId,
|
|
318
|
+
station: target.station,
|
|
319
|
+
accountId: target.accountId,
|
|
320
|
+
enabled,
|
|
321
|
+
activated: await activate(deps, target.station),
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
297
325
|
async function handleSession(
|
|
298
326
|
req: IncomingMessage,
|
|
299
327
|
res: ServerResponse,
|
|
@@ -345,6 +373,7 @@ async function dispatchRoute(
|
|
|
345
373
|
if (route.kind === 'step')
|
|
346
374
|
return handleStep(req, res, deps, ownerOf(session, agentId), route.attachId);
|
|
347
375
|
if (route.kind === 'allowlist') return handleAllowlist(req, res, deps, session, agentId, route);
|
|
376
|
+
if (route.kind === 'enabled') return handleEnabled(req, res, deps, session, agentId, route);
|
|
348
377
|
if (route.kind === 'senders') {
|
|
349
378
|
sendJson(req, res, 200, { senders: deps.recentSenders(route.station, route.accountId) });
|
|
350
379
|
return;
|
|
@@ -47,7 +47,7 @@ function stationOf(raw: unknown): LoadedAccount {
|
|
|
47
47
|
const allowlist = Array.isArray(raw.allowlist)
|
|
48
48
|
? raw.allowlist.filter((s): s is string => typeof s === 'string')
|
|
49
49
|
: null;
|
|
50
|
-
return { station: raw.station as LoadedAccount['station'], id: raw.id, allowlist, config: raw.config };
|
|
50
|
+
return { station: raw.station as LoadedAccount['station'], id: raw.id, allowlist, enabled: raw.enabled !== false, config: raw.config };
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
function connectorOf(raw: unknown): LoadedConnector {
|
|
@@ -305,7 +305,7 @@ export async function localAttachAccount(
|
|
|
305
305
|
if (typeof token === 'string') assertTokenFree(storedAgents(dir), station, token);
|
|
306
306
|
const taken = new Set(stored.file.stations.map((a) => a.id));
|
|
307
307
|
const accountId = freshId(taken);
|
|
308
|
-
stored.file.stations.push({ station, id: accountId, allowlist: ['*'], config });
|
|
308
|
+
stored.file.stations.push({ station, id: accountId, allowlist: ['*'], enabled: true, config });
|
|
309
309
|
save(stored);
|
|
310
310
|
return Promise.resolve({ agentId, station, accountId });
|
|
311
311
|
}
|
|
@@ -326,6 +326,22 @@ export async function localSetAllowlist(
|
|
|
326
326
|
return Promise.resolve(allowlist);
|
|
327
327
|
}
|
|
328
328
|
|
|
329
|
+
export async function localSetAccountEnabled(
|
|
330
|
+
subject: string,
|
|
331
|
+
agentId: string,
|
|
332
|
+
station: StationName,
|
|
333
|
+
accountId: string,
|
|
334
|
+
enabled: boolean,
|
|
335
|
+
dir = agentsDir(),
|
|
336
|
+
): Promise<boolean> {
|
|
337
|
+
const stored = ownedOrThrow(subject, agentId, dir);
|
|
338
|
+
const account = stored.file.stations.find((a) => a.station === station && a.id === accountId);
|
|
339
|
+
if (account === undefined) throw new AgentAdminError('no such account on this agent', 404);
|
|
340
|
+
account.enabled = enabled;
|
|
341
|
+
save(stored);
|
|
342
|
+
return Promise.resolve(enabled);
|
|
343
|
+
}
|
|
344
|
+
|
|
329
345
|
export async function localDetachAccount(
|
|
330
346
|
subject: string,
|
|
331
347
|
agentId: string,
|
|
@@ -56,7 +56,7 @@ function stationOf(raw: unknown, path: string, index: number): LoadedAccount {
|
|
|
56
56
|
if (typeof id !== 'string' || !ID_RE.test(id))
|
|
57
57
|
fail(path, `${where}.id is not an 11-character id`);
|
|
58
58
|
if (!isRecord(config)) fail(path, `${where}.config is not an object`);
|
|
59
|
-
return { station, id, allowlist: allowlistOf(raw.allowlist, path, where), config };
|
|
59
|
+
return { station, id, allowlist: allowlistOf(raw.allowlist, path, where), enabled: raw.enabled !== false, config };
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
function optionalMatch(
|
|
@@ -8,6 +8,7 @@ const mapKey = (station: string, accountId: string): string =>
|
|
|
8
8
|
let agentMap: AgentMap = {};
|
|
9
9
|
let agentNames: AgentNameMap = {};
|
|
10
10
|
let allowlistMap: AllowlistMap = {};
|
|
11
|
+
let disabledAccounts = new Set<string>();
|
|
11
12
|
|
|
12
13
|
export function setAgentMap(map: AgentMap, names: AgentNameMap): void {
|
|
13
14
|
agentMap = map;
|
|
@@ -18,6 +19,12 @@ export function setAllowlistMap(map: AllowlistMap): void {
|
|
|
18
19
|
allowlistMap = map;
|
|
19
20
|
}
|
|
20
21
|
|
|
22
|
+
export function setDisabledAccounts(ids: Set<string>): void {
|
|
23
|
+
disabledAccounts = ids;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const accountEnabled = (station: string, accountId: string): boolean => !disabledAccounts.has(mapKey(station, accountId));
|
|
27
|
+
|
|
21
28
|
export function accountFromLine(
|
|
22
29
|
line: string,
|
|
23
30
|
): { station: string; accountId: string } | undefined {
|
|
@@ -4,8 +4,8 @@ import {
|
|
|
4
4
|
stationByName,
|
|
5
5
|
} from '../stations/registry.js';
|
|
6
6
|
import { listEndpoints } from '../net/tunnel.js';
|
|
7
|
-
import { hookUrl } from '../stations/attach.js';
|
|
8
|
-
import { agentIdForAccount, allowlistForAccount, knownAccounts, type KnownAccount } from '../agents/map.js';
|
|
7
|
+
import { hookUrl, threemaCallbackUrl } from '../stations/attach.js';
|
|
8
|
+
import { accountEnabled, agentIdForAccount, allowlistForAccount, knownAccounts, type KnownAccount } from '../agents/map.js';
|
|
9
9
|
|
|
10
10
|
const accountId = (acc: unknown): string | undefined => {
|
|
11
11
|
const id = (acc as { id?: unknown }).id;
|
|
@@ -25,7 +25,8 @@ function withAgentId(station: string, acc: unknown): unknown {
|
|
|
25
25
|
const agentId = agentIdForAccount(station, id);
|
|
26
26
|
if (agentId === undefined) return acc;
|
|
27
27
|
const allowlist = allowlistForAccount(station, id);
|
|
28
|
-
|
|
28
|
+
const tagged = allowlist === undefined ? { ...rec, agentId } : { ...rec, agentId, allowlist };
|
|
29
|
+
return accountEnabled(station, id) ? tagged : { ...tagged, enabled: false };
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
export function attachAgentIds(
|
|
@@ -71,6 +72,17 @@ function inCoreAccounts(station: string): unknown[] {
|
|
|
71
72
|
);
|
|
72
73
|
}
|
|
73
74
|
|
|
75
|
+
function withCallbackUrl(acc: unknown): unknown {
|
|
76
|
+
const rec = asRecord(acc);
|
|
77
|
+
if (rec === undefined) return acc;
|
|
78
|
+
const { callbackId, callbackToken, ...rest } = rec;
|
|
79
|
+
if (typeof callbackId !== 'string' || typeof callbackToken !== 'string') return rest;
|
|
80
|
+
return { ...rest, callback: threemaCallbackUrl(callbackId, callbackToken) };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const decorate = (station: string, rows: unknown[]): unknown[] =>
|
|
84
|
+
station === 'threema' ? rows.map(withCallbackUrl) : rows;
|
|
85
|
+
|
|
74
86
|
export interface ScopedAccounts {
|
|
75
87
|
accounts: Record<string, unknown[]>;
|
|
76
88
|
unavailable: string[];
|
|
@@ -84,7 +96,7 @@ async function liveAccounts(
|
|
|
84
96
|
try {
|
|
85
97
|
const resp = await forwardTrainCall(station, 'accounts', {});
|
|
86
98
|
const list = (resp.result as { accounts?: unknown[] } | undefined)?.accounts;
|
|
87
|
-
return { rows: Array.isArray(list) ? list : [], reachable: true };
|
|
99
|
+
return { rows: decorate(station, Array.isArray(list) ? list : []), reachable: true };
|
|
88
100
|
} catch {
|
|
89
101
|
return { rows: [], reachable: false };
|
|
90
102
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { IncomingMessage } from 'node:http';
|
|
2
|
+
|
|
3
|
+
export const WEBHOOK_BODY_MAX = 25 * 1024 * 1024;
|
|
4
|
+
|
|
5
|
+
export class BodyTooLargeError extends Error {
|
|
6
|
+
constructor(readonly limit: number) {
|
|
7
|
+
super(`request body exceeds ${limit} bytes`);
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export async function readBody(
|
|
12
|
+
req: IncomingMessage,
|
|
13
|
+
maxBytes: number,
|
|
14
|
+
): Promise<Buffer> {
|
|
15
|
+
const chunks: Buffer[] = [];
|
|
16
|
+
let total = 0;
|
|
17
|
+
for await (const c of req) {
|
|
18
|
+
const buf = c as Buffer;
|
|
19
|
+
total += buf.length;
|
|
20
|
+
if (total > maxBytes) throw new BodyTooLargeError(maxBytes);
|
|
21
|
+
chunks.push(buf);
|
|
22
|
+
}
|
|
23
|
+
return Buffer.concat(chunks);
|
|
24
|
+
}
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { handleSessionApis, type SessionApis } from './session-apis.js';
|
|
2
2
|
import { handleGatewayRequest } from '../gateway/gateway.js';
|
|
3
3
|
import { handleRelayRequest } from '../connectors/relay.js';
|
|
4
|
+
import { handleThreemaCallback } from './threema-callback.js';
|
|
5
|
+
import { BodyTooLargeError, readBody, WEBHOOK_BODY_MAX } from './body.js';
|
|
6
|
+
|
|
7
|
+
export { BodyTooLargeError, readBody } from './body.js';
|
|
4
8
|
import {
|
|
5
9
|
createServer,
|
|
6
10
|
type IncomingMessage,
|
|
@@ -214,29 +218,6 @@ function isMcpPath(req: IncomingMessage): boolean {
|
|
|
214
218
|
return path === '/' || path === '/mcp';
|
|
215
219
|
}
|
|
216
220
|
|
|
217
|
-
export const WEBHOOK_BODY_MAX = 25 * 1024 * 1024;
|
|
218
|
-
|
|
219
|
-
export class BodyTooLargeError extends Error {
|
|
220
|
-
constructor(readonly limit: number) {
|
|
221
|
-
super(`request body exceeds ${limit} bytes`);
|
|
222
|
-
}
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
export async function readBody(
|
|
226
|
-
req: IncomingMessage,
|
|
227
|
-
maxBytes: number,
|
|
228
|
-
): Promise<Buffer> {
|
|
229
|
-
const chunks: Buffer[] = [];
|
|
230
|
-
let total = 0;
|
|
231
|
-
for await (const c of req) {
|
|
232
|
-
const buf = c as Buffer;
|
|
233
|
-
total += buf.length;
|
|
234
|
-
if (total > maxBytes) throw new BodyTooLargeError(maxBytes);
|
|
235
|
-
chunks.push(buf);
|
|
236
|
-
}
|
|
237
|
-
return Buffer.concat(chunks);
|
|
238
|
-
}
|
|
239
|
-
|
|
240
221
|
function flatHeaders(req: IncomingMessage): Record<string, string> {
|
|
241
222
|
return Object.fromEntries(
|
|
242
223
|
Object.entries(req.headers).map(([k, v]) => [
|
|
@@ -368,6 +349,7 @@ async function handlePreMcpRoutes(
|
|
|
368
349
|
if (handleAttachRequest(req, res)) return true;
|
|
369
350
|
if (apis.relayApi && handleRelayRequest(req, res, apis.relayApi)) return true;
|
|
370
351
|
if (await handleWebhookRoute(req, res, emit)) return true;
|
|
352
|
+
if (await handleThreemaCallback(req, res)) return true;
|
|
371
353
|
return Boolean(monitorCall && handleMonitorRequest(req, res, monitorCall));
|
|
372
354
|
}
|
|
373
355
|
|
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
localDeleteAgent,
|
|
37
37
|
localDetachAccount,
|
|
38
38
|
localSetAllowlist,
|
|
39
|
+
localSetAccountEnabled,
|
|
39
40
|
localImportAgent,
|
|
40
41
|
localListAgents,
|
|
41
42
|
localOwnedAgentOrThrow,
|
|
@@ -103,6 +104,7 @@ function agentApi(deps: LocalModeDeps): AgentApiDeps {
|
|
|
103
104
|
detachAccount: localDetachAccount,
|
|
104
105
|
syncStations: deps.syncStations,
|
|
105
106
|
setAllowlist: localSetAllowlist,
|
|
107
|
+
setAccountEnabled: localSetAccountEnabled,
|
|
106
108
|
recentSenders,
|
|
107
109
|
reloadAgents: deps.reloadAgents,
|
|
108
110
|
};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import { errMsg, log } from '@metro-labs/core/log';
|
|
3
|
+
import { tokenMatches } from '../net/tunnel.js';
|
|
4
|
+
import {
|
|
5
|
+
findThreemaCallback,
|
|
6
|
+
type ThreemaCallback,
|
|
7
|
+
} from '../stations/threema-callbacks.js';
|
|
8
|
+
import { forwardTrainCall, type TrainCallBackend } from '../stations/train-call.js';
|
|
9
|
+
import { BodyTooLargeError, readBody } from './body.js';
|
|
10
|
+
|
|
11
|
+
const PREFIX = '/api/threema/';
|
|
12
|
+
const CALLBACK_PATH = /^\/api\/threema\/([0-9]{17,20})\/([A-Za-z0-9_-]{32,128})$/;
|
|
13
|
+
const BODY_MAX = 64 * 1024;
|
|
14
|
+
const REQUIRED = ['from', 'to', 'messageId', 'date', 'nonce', 'box', 'mac'] as const;
|
|
15
|
+
|
|
16
|
+
function callbackTarget(path: string): ThreemaCallback | null {
|
|
17
|
+
const m = CALLBACK_PATH.exec(path);
|
|
18
|
+
const callbackId = m?.[1];
|
|
19
|
+
const token = m?.[2];
|
|
20
|
+
if (callbackId === undefined || token === undefined) return null;
|
|
21
|
+
const row = findThreemaCallback(callbackId);
|
|
22
|
+
return row !== undefined && tokenMatches(row.callbackToken, token) ? row : null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function parseFields(raw: Buffer): Record<string, string> | null {
|
|
26
|
+
const params = new URLSearchParams(raw.toString('utf8'));
|
|
27
|
+
const out: Record<string, string> = {};
|
|
28
|
+
for (const key of REQUIRED) {
|
|
29
|
+
const value = params.get(key)?.trim() ?? '';
|
|
30
|
+
if (value === '') return null;
|
|
31
|
+
out[key] = value;
|
|
32
|
+
}
|
|
33
|
+
const nickname = params.get('nickname')?.trim() ?? '';
|
|
34
|
+
if (nickname !== '') out.nickname = nickname;
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function deliver(
|
|
39
|
+
res: ServerResponse,
|
|
40
|
+
target: ThreemaCallback,
|
|
41
|
+
fields: Record<string, string>,
|
|
42
|
+
forward: TrainCallBackend,
|
|
43
|
+
): Promise<void> {
|
|
44
|
+
let response;
|
|
45
|
+
try {
|
|
46
|
+
response = await forward('threema', 'callback', { account: target.id, ...fields });
|
|
47
|
+
} catch (err) {
|
|
48
|
+
log.warn(
|
|
49
|
+
{ account: target.id, err: errMsg(err) },
|
|
50
|
+
'threema: the callback could not reach the train, so Threema is asked to retry',
|
|
51
|
+
);
|
|
52
|
+
res.writeHead(503).end('threema train unavailable');
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (response.error !== undefined) {
|
|
56
|
+
log.warn({ account: target.id, err: response.error }, 'threema: callback refused');
|
|
57
|
+
res.writeHead(400).end('refused');
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
res.writeHead(200).end('ok');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function readFields(
|
|
64
|
+
req: IncomingMessage,
|
|
65
|
+
res: ServerResponse,
|
|
66
|
+
): Promise<Record<string, string> | null> {
|
|
67
|
+
let raw: Buffer;
|
|
68
|
+
try {
|
|
69
|
+
raw = await readBody(req, BODY_MAX);
|
|
70
|
+
} catch (err) {
|
|
71
|
+
if (!(err instanceof BodyTooLargeError)) throw err;
|
|
72
|
+
res.writeHead(413).end('payload too large');
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
const fields = parseFields(raw);
|
|
76
|
+
if (fields === null) res.writeHead(400).end('missing callback fields');
|
|
77
|
+
return fields;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export async function handleThreemaCallback(
|
|
81
|
+
req: IncomingMessage,
|
|
82
|
+
res: ServerResponse,
|
|
83
|
+
forward: TrainCallBackend = forwardTrainCall,
|
|
84
|
+
): Promise<boolean> {
|
|
85
|
+
const path = (req.url ?? '').split('?')[0] ?? '';
|
|
86
|
+
if (!path.startsWith(PREFIX)) return false;
|
|
87
|
+
const target = callbackTarget(path);
|
|
88
|
+
if (target === null) {
|
|
89
|
+
res.writeHead(404).end();
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
if (req.method === 'GET') {
|
|
93
|
+
res.writeHead(200).end(`metro threema callback ${target.callbackId} ready\n`);
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
if (req.method !== 'POST') {
|
|
97
|
+
res.writeHead(405).end();
|
|
98
|
+
return true;
|
|
99
|
+
}
|
|
100
|
+
const fields = await readFields(req, res);
|
|
101
|
+
if (fields !== null) await deliver(res, target, fields, forward);
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
@@ -7,7 +7,13 @@ import {
|
|
|
7
7
|
TelegramTokenError,
|
|
8
8
|
verifyTelegramBotToken,
|
|
9
9
|
} from '@metro-labs/telegram-bot/verify';
|
|
10
|
+
import {
|
|
11
|
+
parsePrivateKey,
|
|
12
|
+
ThreemaGatewayError,
|
|
13
|
+
verifyThreemaGateway,
|
|
14
|
+
} from '@metro-labs/threema/verify';
|
|
10
15
|
import { ApiError } from '@metro-labs/http/api-error';
|
|
16
|
+
import { bodyField } from '@metro-labs/http/api-http';
|
|
11
17
|
import { ensureStationDeps } from './runtime-deps.js';
|
|
12
18
|
import { publicBaseOrDefault } from '../files/attach-serve.js';
|
|
13
19
|
import {
|
|
@@ -21,6 +27,7 @@ import {
|
|
|
21
27
|
export const ATTACHABLE_STATIONS = [
|
|
22
28
|
'discord-bot',
|
|
23
29
|
'telegram-bot',
|
|
30
|
+
'threema',
|
|
24
31
|
'xmtp',
|
|
25
32
|
'webhook',
|
|
26
33
|
] as const;
|
|
@@ -32,6 +39,9 @@ export class StationAttachError extends ApiError {}
|
|
|
32
39
|
export interface AttachInput {
|
|
33
40
|
station: AttachStation;
|
|
34
41
|
token?: unknown;
|
|
42
|
+
gatewayId?: unknown;
|
|
43
|
+
secret?: unknown;
|
|
44
|
+
privateKey?: unknown;
|
|
35
45
|
}
|
|
36
46
|
|
|
37
47
|
export interface OneTimeSecret {
|
|
@@ -53,6 +63,16 @@ const SECP256K1_ORDER = BigInt(
|
|
|
53
63
|
'0xfffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364141',
|
|
54
64
|
);
|
|
55
65
|
|
|
66
|
+
export function attachInputOf(station: AttachStation, body: unknown): AttachInput {
|
|
67
|
+
return {
|
|
68
|
+
station,
|
|
69
|
+
token: bodyField(body, 'token'),
|
|
70
|
+
gatewayId: bodyField(body, 'gatewayId'),
|
|
71
|
+
secret: bodyField(body, 'secret'),
|
|
72
|
+
privateKey: bodyField(body, 'privateKey'),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
56
76
|
export function isAttachStation(raw: unknown): raw is AttachStation {
|
|
57
77
|
return (
|
|
58
78
|
typeof raw === 'string' &&
|
|
@@ -163,6 +183,49 @@ export function newWebhookId(): string {
|
|
|
163
183
|
return String(WEBHOOK_ID_FLOOR + (raw % (9n * WEBHOOK_ID_FLOOR)));
|
|
164
184
|
}
|
|
165
185
|
|
|
186
|
+
export const threemaCallbackUrl = (callbackId: string, token: string): string =>
|
|
187
|
+
`${publicBaseOrDefault().replace(/\/+$/, '')}/api/threema/${callbackId}/${token}`;
|
|
188
|
+
|
|
189
|
+
function requireText(raw: unknown, label: string): string {
|
|
190
|
+
const text = typeof raw === 'string' ? raw.trim() : '';
|
|
191
|
+
if (text === '')
|
|
192
|
+
throw new StationAttachError(`the Threema ${label} is required`, 400);
|
|
193
|
+
return text;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function prepareThreema(input: AttachInput): Promise<PreparedAccount> {
|
|
197
|
+
const gatewayId = requireText(input.gatewayId, 'Gateway ID');
|
|
198
|
+
const secret = requireText(input.secret, 'API secret');
|
|
199
|
+
const privateKey = requireText(input.privateKey, 'private key');
|
|
200
|
+
try {
|
|
201
|
+
const identity = await verifyThreemaGateway({ gatewayId, secret, privateKey });
|
|
202
|
+
const callbackId = newWebhookId();
|
|
203
|
+
const callbackToken = randomBytes(48).toString('base64url');
|
|
204
|
+
return {
|
|
205
|
+
config: {
|
|
206
|
+
gatewayId: identity.gatewayId,
|
|
207
|
+
secret,
|
|
208
|
+
privateKey: parsePrivateKey(privateKey) ?? privateKey,
|
|
209
|
+
callbackId,
|
|
210
|
+
callbackToken,
|
|
211
|
+
createdAt: new Date().toISOString(),
|
|
212
|
+
},
|
|
213
|
+
identity: {
|
|
214
|
+
gatewayId: identity.gatewayId,
|
|
215
|
+
credits: String(identity.credits),
|
|
216
|
+
callback: threemaCallbackUrl(callbackId, callbackToken),
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
} catch (err) {
|
|
220
|
+
if (err instanceof StationAttachError) throw err;
|
|
221
|
+
return rejected(
|
|
222
|
+
err,
|
|
223
|
+
err instanceof ThreemaGatewayError,
|
|
224
|
+
'Threema rejected those Gateway credentials',
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
166
229
|
function prepareWebhook(): PreparedAccount {
|
|
167
230
|
const secret = randomBytes(48).toString('base64url');
|
|
168
231
|
const webhookId = newWebhookId();
|
|
@@ -180,5 +243,6 @@ export async function prepareAccount(
|
|
|
180
243
|
if (input.station === 'discord-bot') return prepareDiscord(input.token);
|
|
181
244
|
if (input.station === 'telegram-bot') return prepareTelegram(input.token);
|
|
182
245
|
if (input.station === 'webhook') return prepareWebhook();
|
|
246
|
+
if (input.station === 'threema') return prepareThreema(input);
|
|
183
247
|
return prepareXmtp(verify);
|
|
184
248
|
}
|
|
@@ -13,6 +13,7 @@ import { trainsDir } from '../boot/paths.js';
|
|
|
13
13
|
import {
|
|
14
14
|
setAgentMap,
|
|
15
15
|
setAllowlistMap,
|
|
16
|
+
setDisabledAccounts,
|
|
16
17
|
type AgentMap,
|
|
17
18
|
type AgentNameMap,
|
|
18
19
|
type AllowlistMap,
|
|
@@ -30,6 +31,7 @@ export interface LoadedAccount {
|
|
|
30
31
|
station: StationName;
|
|
31
32
|
id: string;
|
|
32
33
|
allowlist: string[] | null;
|
|
34
|
+
enabled?: boolean;
|
|
33
35
|
config: Record<string, unknown>;
|
|
34
36
|
}
|
|
35
37
|
|
|
@@ -77,6 +79,11 @@ const STATION_TARGETS: Record<StationName, StationTarget> = {
|
|
|
77
79
|
fileEnv: 'WHATSAPP_ACCOUNTS_FILE',
|
|
78
80
|
trainImport: '@metro-labs/whatsapp/train',
|
|
79
81
|
},
|
|
82
|
+
threema: {
|
|
83
|
+
file: 'threema-accounts.json',
|
|
84
|
+
fileEnv: 'THREEMA_ACCOUNTS_FILE',
|
|
85
|
+
trainImport: '@metro-labs/threema/train',
|
|
86
|
+
},
|
|
80
87
|
webhook: {
|
|
81
88
|
file: 'webhook-accounts.json',
|
|
82
89
|
fileEnv: 'WEBHOOK_ACCOUNTS_FILE',
|
|
@@ -84,7 +91,7 @@ const STATION_TARGETS: Record<StationName, StationTarget> = {
|
|
|
84
91
|
},
|
|
85
92
|
};
|
|
86
93
|
|
|
87
|
-
function accountFilePath(station: StationName): string {
|
|
94
|
+
export function accountFilePath(station: StationName): string {
|
|
88
95
|
const target = STATION_TARGETS[station];
|
|
89
96
|
return process.env[target.fileEnv] ?? join(METRO_DIR, target.file);
|
|
90
97
|
}
|
|
@@ -126,6 +133,7 @@ function writeStations(list: LoadedAgent[]): WrittenStations {
|
|
|
126
133
|
const map: AgentMap = {};
|
|
127
134
|
const names: AgentNameMap = {};
|
|
128
135
|
const allow: AllowlistMap = {};
|
|
136
|
+
const disabled = new Set<string>();
|
|
129
137
|
for (const agent of list) {
|
|
130
138
|
names[agent.id] = agent.name;
|
|
131
139
|
for (const a of agent.accounts) {
|
|
@@ -138,6 +146,10 @@ function writeStations(list: LoadedAgent[]): WrittenStations {
|
|
|
138
146
|
}
|
|
139
147
|
map[`${a.station}/${a.id}`] = agent.id;
|
|
140
148
|
if (a.allowlist) allow[`${a.station}/${a.id}`] = a.allowlist;
|
|
149
|
+
if (a.enabled === false) {
|
|
150
|
+
disabled.add(`${a.station}/${a.id}`);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
141
153
|
const cur = byStation.get(a.station);
|
|
142
154
|
if (cur) cur.push(a);
|
|
143
155
|
else byStation.set(a.station, [a]);
|
|
@@ -145,6 +157,7 @@ function writeStations(list: LoadedAgent[]): WrittenStations {
|
|
|
145
157
|
}
|
|
146
158
|
setAgentMap(map, names);
|
|
147
159
|
setAllowlistMap(allow);
|
|
160
|
+
setDisabledAccounts(disabled);
|
|
148
161
|
|
|
149
162
|
const active = new Map<StationName, number>();
|
|
150
163
|
const changed: StationName[] = [];
|
|
@@ -5,6 +5,7 @@ import { telegramBotStation } from '@metro-labs/telegram-bot';
|
|
|
5
5
|
import { telegramStation } from '@metro-labs/telegram';
|
|
6
6
|
import { discordBotStation } from '@metro-labs/discord-bot';
|
|
7
7
|
import { whatsappStation } from '@metro-labs/whatsapp';
|
|
8
|
+
import { threemaStation } from '@metro-labs/threema';
|
|
8
9
|
import { webhookStation } from '@metro-labs/webhook';
|
|
9
10
|
|
|
10
11
|
export const STATIONS: readonly Station[] = [
|
|
@@ -13,6 +14,7 @@ export const STATIONS: readonly Station[] = [
|
|
|
13
14
|
telegramStation,
|
|
14
15
|
discordBotStation,
|
|
15
16
|
whatsappStation,
|
|
17
|
+
threemaStation,
|
|
16
18
|
webhookStation,
|
|
17
19
|
];
|
|
18
20
|
|