@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.
Files changed (31) hide show
  1. package/package.json +1 -1
  2. package/runtime/marketplace/plugin/.claude-plugin/plugin.json +1 -1
  3. package/runtime/node_modules/@metro-labs/core/src/lines.ts +5 -0
  4. package/runtime/node_modules/@metro-labs/core/src/station-names.ts +1 -0
  5. package/runtime/node_modules/@metro-labs/daemon/package.json +1 -0
  6. package/runtime/node_modules/@metro-labs/daemon/src/agents/accounts-api.ts +35 -6
  7. package/runtime/node_modules/@metro-labs/daemon/src/agents/bundle.ts +1 -1
  8. package/runtime/node_modules/@metro-labs/daemon/src/agents/file-admin.ts +17 -1
  9. package/runtime/node_modules/@metro-labs/daemon/src/agents/files.ts +1 -1
  10. package/runtime/node_modules/@metro-labs/daemon/src/agents/map.ts +7 -0
  11. package/runtime/node_modules/@metro-labs/daemon/src/mcp/accounts.ts +16 -4
  12. package/runtime/node_modules/@metro-labs/daemon/src/routes/body.ts +24 -0
  13. package/runtime/node_modules/@metro-labs/daemon/src/routes/http.ts +5 -23
  14. package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +2 -0
  15. package/runtime/node_modules/@metro-labs/daemon/src/routes/threema-callback.ts +103 -0
  16. package/runtime/node_modules/@metro-labs/daemon/src/stations/attach.ts +64 -0
  17. package/runtime/node_modules/@metro-labs/daemon/src/stations/materialize.ts +14 -1
  18. package/runtime/node_modules/@metro-labs/daemon/src/stations/registry.ts +2 -0
  19. package/runtime/node_modules/@metro-labs/daemon/src/stations/threema-callbacks.ts +32 -0
  20. package/runtime/node_modules/@metro-labs/threema/package.json +26 -0
  21. package/runtime/node_modules/@metro-labs/threema/src/accounts.ts +100 -0
  22. package/runtime/node_modules/@metro-labs/threema/src/actions.ts +219 -0
  23. package/runtime/node_modules/@metro-labs/threema/src/api.ts +130 -0
  24. package/runtime/node_modules/@metro-labs/threema/src/crypto.ts +132 -0
  25. package/runtime/node_modules/@metro-labs/threema/src/format.ts +123 -0
  26. package/runtime/node_modules/@metro-labs/threema/src/ids.ts +21 -0
  27. package/runtime/node_modules/@metro-labs/threema/src/index.ts +29 -0
  28. package/runtime/node_modules/@metro-labs/threema/src/station.ts +10 -0
  29. package/runtime/node_modules/@metro-labs/threema/src/verify.ts +117 -0
  30. package/runtime/runtime.json +1 -1
  31. 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.97",
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.97",
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
  };
@@ -4,6 +4,7 @@ export const STATIONS = [
4
4
  'telegram',
5
5
  'discord-bot',
6
6
  'whatsapp',
7
+ 'threema',
7
8
  'webhook',
8
9
  ] as const;
9
10
 
@@ -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
- return allowlist === undefined ? { ...rec, agentId } : { ...rec, agentId, allowlist };
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