@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.
- 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 +2 -4
- package/runtime/node_modules/@metro-labs/daemon/src/mcp/accounts.ts +13 -2
- 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/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 +6 -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,
|
|
@@ -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
|
+
}
|