@stage-labs/metro 0.1.0-beta.98 → 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 (26) 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 +2 -4
  7. package/runtime/node_modules/@metro-labs/daemon/src/mcp/accounts.ts +13 -2
  8. package/runtime/node_modules/@metro-labs/daemon/src/routes/body.ts +24 -0
  9. package/runtime/node_modules/@metro-labs/daemon/src/routes/http.ts +5 -23
  10. package/runtime/node_modules/@metro-labs/daemon/src/routes/threema-callback.ts +103 -0
  11. package/runtime/node_modules/@metro-labs/daemon/src/stations/attach.ts +64 -0
  12. package/runtime/node_modules/@metro-labs/daemon/src/stations/materialize.ts +6 -1
  13. package/runtime/node_modules/@metro-labs/daemon/src/stations/registry.ts +2 -0
  14. package/runtime/node_modules/@metro-labs/daemon/src/stations/threema-callbacks.ts +32 -0
  15. package/runtime/node_modules/@metro-labs/threema/package.json +26 -0
  16. package/runtime/node_modules/@metro-labs/threema/src/accounts.ts +100 -0
  17. package/runtime/node_modules/@metro-labs/threema/src/actions.ts +219 -0
  18. package/runtime/node_modules/@metro-labs/threema/src/api.ts +130 -0
  19. package/runtime/node_modules/@metro-labs/threema/src/crypto.ts +132 -0
  20. package/runtime/node_modules/@metro-labs/threema/src/format.ts +123 -0
  21. package/runtime/node_modules/@metro-labs/threema/src/ids.ts +21 -0
  22. package/runtime/node_modules/@metro-labs/threema/src/index.ts +29 -0
  23. package/runtime/node_modules/@metro-labs/threema/src/station.ts +10 -0
  24. package/runtime/node_modules/@metro-labs/threema/src/verify.ts +117 -0
  25. package/runtime/runtime.json +1 -1
  26. 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.98",
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.98",
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,
@@ -219,10 +220,7 @@ async function handleStart(
219
220
  }
220
221
  if (!isAttachStation(station))
221
222
  throw new ApiError(`station must be one of ${ATTACHABLE.join(', ')}`, 400);
222
- const prepared = await deps.prepareAccount({
223
- station,
224
- token: bodyField(body, 'token'),
225
- });
223
+ const prepared = await deps.prepareAccount(attachInputOf(station, body));
226
224
  const ref = await storeAccount(deps, session, agentId, station, prepared);
227
225
  log.info(
228
226
  { agentId: ref.agentId, station, account: ref.accountId },
@@ -4,7 +4,7 @@ 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';
7
+ import { hookUrl, threemaCallbackUrl } from '../stations/attach.js';
8
8
  import { accountEnabled, agentIdForAccount, allowlistForAccount, knownAccounts, type KnownAccount } from '../agents/map.js';
9
9
 
10
10
  const accountId = (acc: unknown): string | undefined => {
@@ -72,6 +72,17 @@ function inCoreAccounts(station: string): unknown[] {
72
72
  );
73
73
  }
74
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
+
75
86
  export interface ScopedAccounts {
76
87
  accounts: Record<string, unknown[]>;
77
88
  unavailable: string[];
@@ -85,7 +96,7 @@ async function liveAccounts(
85
96
  try {
86
97
  const resp = await forwardTrainCall(station, 'accounts', {});
87
98
  const list = (resp.result as { accounts?: unknown[] } | undefined)?.accounts;
88
- return { rows: Array.isArray(list) ? list : [], reachable: true };
99
+ return { rows: decorate(station, Array.isArray(list) ? list : []), reachable: true };
89
100
  } catch {
90
101
  return { rows: [], reachable: false };
91
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
 
@@ -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
  }
@@ -79,6 +79,11 @@ const STATION_TARGETS: Record<StationName, StationTarget> = {
79
79
  fileEnv: 'WHATSAPP_ACCOUNTS_FILE',
80
80
  trainImport: '@metro-labs/whatsapp/train',
81
81
  },
82
+ threema: {
83
+ file: 'threema-accounts.json',
84
+ fileEnv: 'THREEMA_ACCOUNTS_FILE',
85
+ trainImport: '@metro-labs/threema/train',
86
+ },
82
87
  webhook: {
83
88
  file: 'webhook-accounts.json',
84
89
  fileEnv: 'WEBHOOK_ACCOUNTS_FILE',
@@ -86,7 +91,7 @@ const STATION_TARGETS: Record<StationName, StationTarget> = {
86
91
  },
87
92
  };
88
93
 
89
- function accountFilePath(station: StationName): string {
94
+ export function accountFilePath(station: StationName): string {
90
95
  const target = STATION_TARGETS[station];
91
96
  return process.env[target.fileEnv] ?? join(METRO_DIR, target.file);
92
97
  }
@@ -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
 
@@ -0,0 +1,32 @@
1
+ import { readJson } from '@metro-labs/core/secure-fs';
2
+ import { accountFilePath } from './materialize.js';
3
+
4
+ export interface ThreemaCallback {
5
+ id: string;
6
+ callbackId: string;
7
+ callbackToken: string;
8
+ }
9
+
10
+ const str = (v: unknown): string | undefined =>
11
+ typeof v === 'string' && v !== '' ? v : undefined;
12
+
13
+ function toCallback(row: unknown): ThreemaCallback | null {
14
+ if (typeof row !== 'object' || row === null) return null;
15
+ const rec = row as Record<string, unknown>;
16
+ const id = str(rec.id);
17
+ const callbackId = str(rec.callbackId);
18
+ const callbackToken = str(rec.callbackToken);
19
+ if (id === undefined || callbackId === undefined || callbackToken === undefined) return null;
20
+ return { id, callbackId, callbackToken };
21
+ }
22
+
23
+ export function listThreemaCallbacks(): ThreemaCallback[] {
24
+ const raw = readJson<unknown[]>(accountFilePath('threema'), [], {
25
+ warn: 'threema-accounts.json: malformed, ignoring',
26
+ });
27
+ if (!Array.isArray(raw)) return [];
28
+ return raw.map(toCallback).filter((c): c is ThreemaCallback => c !== null);
29
+ }
30
+
31
+ export const findThreemaCallback = (callbackId: string): ThreemaCallback | undefined =>
32
+ listThreemaCallbacks().find((c) => c.callbackId === callbackId);
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "@metro-labs/threema",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "exports": {
7
+ ".": "./src/station.ts",
8
+ "./train": "./src/index.ts",
9
+ "./verify": "./src/verify.ts"
10
+ },
11
+ "scripts": {
12
+ "build": "tsc",
13
+ "test": "tsc --noEmit && bun test test/"
14
+ },
15
+ "dependencies": {
16
+ "@metro-labs/core": "workspace:*",
17
+ "tweetnacl": "^1.0.3"
18
+ },
19
+ "devDependencies": {
20
+ "@stage-labs/config": "0.1.0-beta.2",
21
+ "eslint": "^10.3.0",
22
+ "knip": "^6.15.0",
23
+ "madge": "^8.0.0",
24
+ "typescript": "^5"
25
+ }
26
+ }
@@ -0,0 +1,100 @@
1
+ import { homedir } from 'node:os';
2
+ import { join } from 'node:path';
3
+ import {
4
+ makeAccountStore,
5
+ resolveAccountId,
6
+ type Die,
7
+ } from '@metro-labs/core/stations/account-store';
8
+ import { Line } from '@metro-labs/core/lines';
9
+ import { fetchPublicKey } from './api.js';
10
+ import { hexToBytes, keyPairFrom, type KeyPair } from './crypto.js';
11
+ import { isGatewayId, normalizeThreemaId, parsePrivateKey } from './ids.js';
12
+
13
+ const ACCOUNTS_FILE =
14
+ process.env.THREEMA_ACCOUNTS_FILE ??
15
+ join(homedir(), '.metro', 'threema-accounts.json');
16
+
17
+ export interface AccountConfig {
18
+ id: string;
19
+ gatewayId: string;
20
+ secret: string;
21
+ privateKey: string;
22
+ callbackId?: string;
23
+ callbackToken?: string;
24
+ owner?: string;
25
+ }
26
+
27
+ function checkAccount(a: AccountConfig, die: Die): void {
28
+ if (!a.id) die('account missing id');
29
+ if (typeof a.gatewayId !== 'string' || !isGatewayId(a.gatewayId))
30
+ die(`account '${a.id}' has no Gateway ID`);
31
+ if (typeof a.secret !== 'string' || a.secret === '')
32
+ die(`account '${a.id}' missing secret`);
33
+ if (typeof a.privateKey !== 'string' || parsePrivateKey(a.privateKey) === null)
34
+ die(`account '${a.id}' has no usable private key`);
35
+ }
36
+
37
+ export const { loadAccounts } = makeAccountStore<AccountConfig>({
38
+ prefix: 'threema',
39
+ file: ACCOUNTS_FILE,
40
+ validate(raw, die) {
41
+ const seenId = new Set<string>();
42
+ const seenGateway = new Set<string>();
43
+ for (const a of raw) {
44
+ checkAccount(a, die);
45
+ if (seenId.has(a.id)) die(`duplicate account id '${a.id}'`);
46
+ if (seenGateway.has(a.gatewayId))
47
+ die(`account '${a.id}' reuses the Gateway ID of another account`);
48
+ seenId.add(a.id);
49
+ seenGateway.add(a.gatewayId);
50
+ }
51
+ },
52
+ });
53
+
54
+ export interface Account {
55
+ cfg: AccountConfig;
56
+ keys: KeyPair;
57
+ publicKeys: Map<string, Uint8Array>;
58
+ }
59
+
60
+ export const accounts = new Map<string, Account>();
61
+
62
+ export function bootAccount(cfg: AccountConfig): Account {
63
+ const key = parsePrivateKey(cfg.privateKey);
64
+ if (key === null) throw new Error(`account '${cfg.id}' has no usable private key`);
65
+ return { cfg, keys: keyPairFrom(key), publicKeys: new Map() };
66
+ }
67
+
68
+ export function accountFor(id: string): Account {
69
+ const acct = accounts.get(id);
70
+ if (!acct)
71
+ throw new Error(
72
+ `unknown account '${id}' (have: ${[...accounts.keys()].join(', ')})`,
73
+ );
74
+ return acct;
75
+ }
76
+
77
+ export function targetOf(
78
+ line: string,
79
+ account?: string,
80
+ ): { acct: Account; to: string } {
81
+ const parsed = Line.parseThreema(line);
82
+ if (parsed === null) throw new Error(`not a threema line: ${line}`);
83
+ const id = resolveAccountId(
84
+ accounts,
85
+ { account, line },
86
+ (l) => Line.parseThreema(l)?.accountId,
87
+ );
88
+ return { acct: accountFor(id), to: normalizeThreemaId(parsed.resource) };
89
+ }
90
+
91
+ export async function publicKeyFor(
92
+ acct: Account,
93
+ threemaId: string,
94
+ ): Promise<Uint8Array> {
95
+ const cached = acct.publicKeys.get(threemaId);
96
+ if (cached) return cached;
97
+ const key = hexToBytes(await fetchPublicKey(acct.cfg, threemaId), 'public key');
98
+ acct.publicKeys.set(threemaId, key);
99
+ return key;
100
+ }