@stage-labs/metro 0.1.0-beta.225 → 0.1.0-beta.226
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/daemon/src/connectors/transfer-api.ts +116 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/transfer-crypto.ts +44 -0
- package/runtime/node_modules/@metro-labs/daemon/src/connectors/transfer-store.ts +48 -0
- package/runtime/node_modules/@metro-labs/daemon/src/routes/local-mode.ts +3 -0
- package/runtime/node_modules/@metro-labs/daemon/src/routes/session-apis.ts +3 -0
- package/runtime/node_modules/@metro-labs/xmtp/src/actions-meta.ts +36 -10
- package/runtime/node_modules/@metro-labs/xmtp/src/conv-helpers.ts +8 -4
- package/runtime/node_modules/@metro-labs/xmtp/src/labels.ts +58 -16
- package/runtime/node_modules/@metro-labs/xmtp/src/tools-handlers.ts +20 -9
- package/runtime/node_modules/@metro-labs/xmtp/src/tools.ts +27 -6
- package/runtime/runtime.json +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "metro",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.226",
|
|
4
4
|
"description": "Metro for Claude Code, installed by the metro daemon on this machine: every connector the agent holds as its own MCP server relayed through the daemon, the orchestrator-only guard on the main thread, and the standing rules loaded at each session start. No vendor credential ever sits in a config file.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Bonustrack",
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import { randomUUID, type KeyObject } from 'node:crypto';
|
|
3
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
4
|
+
import { parseId } from '@metro-labs/core/ids';
|
|
5
|
+
import { log } from '@metro-labs/core/log';
|
|
6
|
+
import { ApiError } from '@metro-labs/http/api-error';
|
|
7
|
+
import { apiSession, cors, readJsonBody, requireAdmin, sendJson } from '@metro-labs/http/api-http';
|
|
8
|
+
import { openTransfer, sealTransfer, transferKeys } from './transfer-crypto.js';
|
|
9
|
+
import type { LocalConnectorRow } from './store.js';
|
|
10
|
+
import type { ConnectorCopyResult } from './transfer-store.js';
|
|
11
|
+
|
|
12
|
+
const PREFIX = '/api/connectors/transfer';
|
|
13
|
+
const MAX_BODY = 4 * 1024 * 1024 + 4096;
|
|
14
|
+
const TTL = 5 * 60_000;
|
|
15
|
+
|
|
16
|
+
export interface ConnectorTransferDeps {
|
|
17
|
+
read: () => LocalConnectorRow[];
|
|
18
|
+
copy: (value: unknown) => ConnectorCopyResult[];
|
|
19
|
+
now?: () => number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface Ticket {
|
|
23
|
+
privateKey: KeyObject;
|
|
24
|
+
organization: string;
|
|
25
|
+
expiresAt: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function selectedIds(raw: unknown): string[] | null {
|
|
29
|
+
if (raw === null) return null;
|
|
30
|
+
if (!Array.isArray(raw) || raw.length === 0 || raw.length > 500) throw new ApiError('choose connectors to copy', 400);
|
|
31
|
+
return [...new Set(raw.map((id: unknown) => {
|
|
32
|
+
if (typeof id !== 'string' || parseId(id) === null) throw new ApiError('choose connectors to copy', 400);
|
|
33
|
+
return id;
|
|
34
|
+
}))];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class ConnectorTransfers {
|
|
38
|
+
private readonly tickets = new Map<string, Ticket>();
|
|
39
|
+
|
|
40
|
+
constructor(private readonly deps: ConnectorTransferDeps) {}
|
|
41
|
+
|
|
42
|
+
private prepare(organization: string): unknown {
|
|
43
|
+
const now = (this.deps.now ?? Date.now)();
|
|
44
|
+
for (const [id, ticket] of this.tickets) if (ticket.expiresAt <= now) this.tickets.delete(id);
|
|
45
|
+
if (this.tickets.size >= 64) throw new ApiError('too many pending connector copies, try again later', 429);
|
|
46
|
+
const { publicKey, privateKey } = transferKeys();
|
|
47
|
+
const ticket = randomUUID();
|
|
48
|
+
this.tickets.set(ticket, { privateKey, organization, expiresAt: now + TTL });
|
|
49
|
+
return { ticket, publicKey };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private export(body: Record<string, unknown>): unknown {
|
|
53
|
+
if (typeof body.publicKey !== 'string') throw new ApiError('invalid transfer key', 400);
|
|
54
|
+
const ids = selectedIds(body.ids);
|
|
55
|
+
const all = this.deps.read();
|
|
56
|
+
const rows = ids === null ? all : all.filter((row) => ids.includes(row.id));
|
|
57
|
+
const expected = ids?.length ?? rows.length;
|
|
58
|
+
if (rows.length !== expected) throw new ApiError('a selected connector no longer exists', 409);
|
|
59
|
+
if (rows.length > 500) throw new ApiError('copy at most 500 connectors at once', 413);
|
|
60
|
+
try {
|
|
61
|
+
return sealTransfer({ version: 1, connectors: rows }, body.publicKey);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
if (err instanceof ApiError) throw err;
|
|
64
|
+
throw new ApiError('invalid transfer key', 400);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private receive(body: Record<string, unknown>, organization: string): unknown {
|
|
69
|
+
const id = typeof body.ticket === 'string' ? body.ticket : '';
|
|
70
|
+
const ticket = this.tickets.get(id);
|
|
71
|
+
if (ticket?.organization !== organization || ticket.expiresAt <= (this.deps.now ?? Date.now)()) {
|
|
72
|
+
this.tickets.delete(id);
|
|
73
|
+
throw new ApiError('this connector copy has expired, start again', 410);
|
|
74
|
+
}
|
|
75
|
+
let value: unknown;
|
|
76
|
+
try {
|
|
77
|
+
value = openTransfer(body.envelope, ticket.privateKey);
|
|
78
|
+
} catch {
|
|
79
|
+
throw new ApiError('invalid encrypted connector copy', 400);
|
|
80
|
+
}
|
|
81
|
+
const results = this.deps.copy(value);
|
|
82
|
+
this.tickets.delete(id);
|
|
83
|
+
return { results };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
private async answer(req: IncomingMessage, path: string): Promise<unknown> {
|
|
87
|
+
const session = await apiSession(req);
|
|
88
|
+
if (session === null) throw new ApiError('unauthorized', 401);
|
|
89
|
+
requireAdmin(session);
|
|
90
|
+
const body = await readJsonBody(req, MAX_BODY);
|
|
91
|
+
if (!isRecord(body) || body.confirmed !== true) throw new ApiError('confirm copying saved logins and settings', 400);
|
|
92
|
+
if (path === `${PREFIX}/prepare`) return this.prepare(session.subject);
|
|
93
|
+
if (path === `${PREFIX}/export`) return this.export(body);
|
|
94
|
+
return this.receive(body, session.subject);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
handle(req: IncomingMessage, res: ServerResponse): boolean {
|
|
98
|
+
const path = (req.url ?? '').split('?')[0] ?? '';
|
|
99
|
+
if (![`${PREFIX}/prepare`, `${PREFIX}/export`, `${PREFIX}/receive`].includes(path)) return false;
|
|
100
|
+
if (req.method === 'OPTIONS') {
|
|
101
|
+
res.writeHead(204, cors(req)).end();
|
|
102
|
+
return true;
|
|
103
|
+
}
|
|
104
|
+
if (req.method !== 'POST') {
|
|
105
|
+
sendJson(req, res, 405, { error: 'method not allowed' });
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
this.answer(req, path).then((body) => {
|
|
109
|
+
sendJson(req, res, 200, body);
|
|
110
|
+
}).catch((err: unknown) => {
|
|
111
|
+
if (!(err instanceof ApiError)) log.warn('connector-transfer: request failed');
|
|
112
|
+
sendJson(req, res, err instanceof ApiError ? err.status : 500, { error: err instanceof ApiError ? err.message : 'connector copy failed' });
|
|
113
|
+
});
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { createCipheriv, createDecipheriv, createPublicKey, diffieHellman, generateKeyPairSync, hkdfSync, randomBytes, type KeyObject } from 'node:crypto';
|
|
2
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
3
|
+
import { ApiError } from '@metro-labs/http/api-error';
|
|
4
|
+
|
|
5
|
+
export interface TransferEnvelope {
|
|
6
|
+
key: string;
|
|
7
|
+
iv: string;
|
|
8
|
+
tag: string;
|
|
9
|
+
data: string;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const transferKeys = (): { publicKey: string; privateKey: KeyObject } => {
|
|
13
|
+
const pair = generateKeyPairSync('x25519');
|
|
14
|
+
return { publicKey: pair.publicKey.export({ type: 'spki', format: 'pem' }).toString(), privateKey: pair.privateKey };
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
function sharedKey(privateKey: KeyObject, pem: string): ArrayBuffer {
|
|
18
|
+
if (pem.length > 1024) throw new ApiError('invalid transfer key', 400);
|
|
19
|
+
const publicKey = createPublicKey(pem);
|
|
20
|
+
if (publicKey.asymmetricKeyType !== 'x25519') throw new ApiError('invalid transfer key', 400);
|
|
21
|
+
return hkdfSync('sha256', diffieHellman({ privateKey, publicKey }), '', 'metro-connector-copy-v1', 32);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function sealTransfer(value: unknown, publicKey: string): TransferEnvelope {
|
|
25
|
+
const pair = transferKeys();
|
|
26
|
+
const iv = randomBytes(12);
|
|
27
|
+
const cipher = createCipheriv('aes-256-gcm', Buffer.from(sharedKey(pair.privateKey, publicKey)), iv);
|
|
28
|
+
const plain = JSON.stringify(value);
|
|
29
|
+
if (Buffer.byteLength(plain) > 3 * 1024 * 1024) throw new ApiError('too many connector settings for one copy', 413);
|
|
30
|
+
const data = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
|
|
31
|
+
return { key: pair.publicKey, iv: iv.toString('base64'), tag: cipher.getAuthTag().toString('base64'), data: data.toString('base64') };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function openTransfer(raw: unknown, privateKey: KeyObject): unknown {
|
|
35
|
+
if (!isRecord(raw) || typeof raw.key !== 'string' || typeof raw.iv !== 'string' || typeof raw.tag !== 'string' || typeof raw.data !== 'string')
|
|
36
|
+
throw new ApiError('invalid encrypted transfer', 400);
|
|
37
|
+
const iv = Buffer.from(raw.iv, 'base64');
|
|
38
|
+
const tag = Buffer.from(raw.tag, 'base64');
|
|
39
|
+
if (iv.length !== 12 || tag.length !== 16) throw new ApiError('invalid encrypted transfer', 400);
|
|
40
|
+
const decipher = createDecipheriv('aes-256-gcm', Buffer.from(sharedKey(privateKey, raw.key)), iv);
|
|
41
|
+
decipher.setAuthTag(tag);
|
|
42
|
+
const plain = Buffer.concat([decipher.update(Buffer.from(raw.data, 'base64')), decipher.final()]);
|
|
43
|
+
return JSON.parse(plain.toString('utf8')) as unknown;
|
|
44
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
2
|
+
import { isRecord } from '@metro-labs/core/is-record';
|
|
3
|
+
import { newId, parseId } from '@metro-labs/core/ids';
|
|
4
|
+
import { ApiError } from '@metro-labs/http/api-error';
|
|
5
|
+
import { connectorAuth, connectorName, readConfig } from './config.js';
|
|
6
|
+
import { parseConnectorUrl } from './url.js';
|
|
7
|
+
import { normalizePolicy } from '../policy/policy.js';
|
|
8
|
+
import { localImportConnectors, readLocalConnectors, type LocalConnectorRow } from './store.js';
|
|
9
|
+
|
|
10
|
+
export interface ConnectorCopyResult {
|
|
11
|
+
sourceId: string | null;
|
|
12
|
+
id?: string;
|
|
13
|
+
status: 'copied' | 'skipped' | 'invalid';
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function copyRow(raw: unknown): LocalConnectorRow {
|
|
17
|
+
if (!isRecord(raw) || typeof raw.id !== 'string' || parseId(raw.id) === null || !isRecord(raw.config)) throw new ApiError('invalid connector', 400);
|
|
18
|
+
const name = connectorName(raw.name);
|
|
19
|
+
const url = parseConnectorUrl(raw.url).toString();
|
|
20
|
+
if (raw.config.policy !== undefined) normalizePolicy(raw.config.policy);
|
|
21
|
+
const config = readConfig(raw.config);
|
|
22
|
+
if (!isDeepStrictEqual(config, raw.config)) throw new ApiError('invalid connector settings', 400);
|
|
23
|
+
if (config.auth.kind === 'header') connectorAuth(config.auth.name, config.auth.value);
|
|
24
|
+
return { id: raw.id, name, url, config };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function copyConnectorRows(value: unknown, dir?: string): ConnectorCopyResult[] {
|
|
28
|
+
if (!isRecord(value) || value.version !== 1 || !Array.isArray(value.connectors) || value.connectors.length > 500)
|
|
29
|
+
throw new ApiError('invalid connector transfer', 400);
|
|
30
|
+
const names = new Set(readLocalConnectors(dir).map((row) => row.name));
|
|
31
|
+
const imported: LocalConnectorRow[] = [];
|
|
32
|
+
const results: ConnectorCopyResult[] = value.connectors.map((raw: unknown) => {
|
|
33
|
+
const sourceId = isRecord(raw) && typeof raw.id === 'string' ? parseId(raw.id) : null;
|
|
34
|
+
let row: LocalConnectorRow;
|
|
35
|
+
try {
|
|
36
|
+
row = copyRow(raw);
|
|
37
|
+
} catch {
|
|
38
|
+
return { sourceId, status: 'invalid' };
|
|
39
|
+
}
|
|
40
|
+
if (names.has(row.name)) return { sourceId, status: 'skipped' };
|
|
41
|
+
names.add(row.name);
|
|
42
|
+
const id = newId();
|
|
43
|
+
imported.push({ ...row, id });
|
|
44
|
+
return { sourceId, id, status: 'copied' };
|
|
45
|
+
});
|
|
46
|
+
if (imported.length > 0) localImportConnectors(imported.map((row) => ({ ...row, config: { ...row.config } })), dir, 'append');
|
|
47
|
+
return results;
|
|
48
|
+
}
|
|
@@ -11,6 +11,8 @@ import type { ModeInfo } from '@metro-labs/http/mode-api';
|
|
|
11
11
|
import { loadedAgentOf, type AgentBundle, type BundleApiDeps } from '../agents/bundle.js';
|
|
12
12
|
import { METRO_VERSION } from '@metro-labs/core/version';
|
|
13
13
|
import type { ConnectorApiDeps } from '../connectors/api.js';
|
|
14
|
+
import { ConnectorTransfers } from '../connectors/transfer-api.js';
|
|
15
|
+
import { copyConnectorRows } from '../connectors/transfer-store.js';
|
|
14
16
|
import { authenticate } from '../mcp/request-identity.js';
|
|
15
17
|
import type { RelayApiDeps } from '../connectors/relay.js';
|
|
16
18
|
import {
|
|
@@ -192,6 +194,7 @@ export function localSessionApis(deps: LocalModeDeps): SessionApis {
|
|
|
192
194
|
agentApi: agentApi(deps),
|
|
193
195
|
bundleApi: bundleApi(deps),
|
|
194
196
|
connectorApi,
|
|
197
|
+
connectorTransfers: new ConnectorTransfers({ read: readLocalConnectors, copy: copyConnectorRows }),
|
|
195
198
|
relayApi,
|
|
196
199
|
claudeApi: { liveEvents: deps.liveEvents },
|
|
197
200
|
updateApi: { restart: deps.restart },
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
type ConnectorApiDeps,
|
|
8
8
|
} from '../connectors/api.js';
|
|
9
9
|
import type { RelayApiDeps } from '../connectors/relay.js';
|
|
10
|
+
import type { ConnectorTransfers } from '../connectors/transfer-api.js';
|
|
10
11
|
import { handleClaudeRequest, type ClaudeApiDeps } from '../claude/api.js';
|
|
11
12
|
import type { ModeInfo } from '@metro-labs/http/mode-api';
|
|
12
13
|
import { handleBundleRequest, type BundleApiDeps } from '../agents/bundle.js';
|
|
@@ -33,6 +34,7 @@ export interface SessionApis {
|
|
|
33
34
|
terminalApi?: TerminalApiDeps;
|
|
34
35
|
claudeApi?: ClaudeApiDeps;
|
|
35
36
|
connectorApi?: ConnectorApiDeps;
|
|
37
|
+
connectorTransfers?: ConnectorTransfers;
|
|
36
38
|
relayApi?: RelayApiDeps;
|
|
37
39
|
mode?: () => ModeInfo;
|
|
38
40
|
}
|
|
@@ -48,6 +50,7 @@ export function handleSessionApis(
|
|
|
48
50
|
const routes: (() => boolean)[] = [
|
|
49
51
|
() => handleSessionApiRequest(req, res),
|
|
50
52
|
() => handleApprovalsRequest(req, res),
|
|
53
|
+
...when(apis.connectorTransfers, (d) => d.handle(req, res)),
|
|
51
54
|
...when(apis.connectorApi, (d) => handleConnectorApiRequest(req, res, d)),
|
|
52
55
|
...when(apis.bundleApi, (d) => handleBundleRequest(req, res, d)),
|
|
53
56
|
...when(apis.updateApi, (d) => handleUpdateRequest(req, res, d)),
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { accountForCall, convOf, lineOf } from './accounts.js';
|
|
1
|
+
import { accountForCall, convOf, lineOf, parseLine } from './accounts.js';
|
|
2
2
|
import { respond } from '@metro-labs/core/stations/station-runtime';
|
|
3
|
-
import { warmGroupName } from './conv-helpers.js';
|
|
4
|
-
import { mergeAppData,
|
|
3
|
+
import { ethIdentifiers, warmGroupName } from './conv-helpers.js';
|
|
4
|
+
import { mergeAppData, normalizeAssigned, readAppDataObject, type GroupLike } from './labels.js';
|
|
5
5
|
import { TrainError } from '@metro-labs/core/train-error';
|
|
6
6
|
|
|
7
7
|
type Args = Record<string, unknown>;
|
|
@@ -50,7 +50,7 @@ async function applyMergedAppData(
|
|
|
50
50
|
await group.updateAppData(res.blob);
|
|
51
51
|
return res.merged;
|
|
52
52
|
}
|
|
53
|
-
return
|
|
53
|
+
return readAppDataObject(group.appData);
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
async function applyChannelMeta(
|
|
@@ -73,7 +73,19 @@ async function applyChannelMeta(
|
|
|
73
73
|
`${verb} target is not a group (no updateAppData)`,
|
|
74
74
|
);
|
|
75
75
|
}
|
|
76
|
-
await
|
|
76
|
+
await conv.sync();
|
|
77
|
+
if (appData) mergeAppData(group.appData, appData);
|
|
78
|
+
else readAppDataObject(group.appData);
|
|
79
|
+
if (appData && Object.hasOwn(appData, 'assigned')) {
|
|
80
|
+
const assigned = normalizeAssigned(appData.assigned);
|
|
81
|
+
const inboxIds = await Promise.all(
|
|
82
|
+
ethIdentifiers(assigned).map((identifier) => acct.client.fetchInboxIdByIdentifier(identifier)),
|
|
83
|
+
);
|
|
84
|
+
const members = new Set((await conv.members()).map((member) => member.inboxId));
|
|
85
|
+
if (inboxIds.some((inboxId) => !inboxId || !members.has(inboxId))) {
|
|
86
|
+
throw new TrainError('INVALID_ARGS', 'Assignees must be current channel members');
|
|
87
|
+
}
|
|
88
|
+
}
|
|
77
89
|
|
|
78
90
|
await applyNameAndDescription(group, name, description);
|
|
79
91
|
const merged = await applyMergedAppData(
|
|
@@ -95,24 +107,38 @@ function metaFields(merged: Record<string, unknown>): {
|
|
|
95
107
|
labels: string[];
|
|
96
108
|
github: string | undefined;
|
|
97
109
|
preview: string | undefined;
|
|
110
|
+
assigned: unknown;
|
|
98
111
|
} {
|
|
99
112
|
return {
|
|
100
113
|
labels: Array.isArray(merged.labels) ? (merged.labels as string[]) : [],
|
|
101
114
|
github: typeof merged.github === 'string' ? merged.github : undefined,
|
|
102
115
|
preview: typeof merged.preview === 'string' ? merged.preview : undefined,
|
|
116
|
+
assigned: Object.hasOwn(merged, 'assigned') ? merged.assigned : [],
|
|
103
117
|
};
|
|
104
118
|
}
|
|
105
119
|
|
|
120
|
+
const metadataWrites = new Map<string, Promise<Record<string, unknown>>>();
|
|
121
|
+
|
|
106
122
|
export async function updateChannelMeta(id: string, args: Args): Promise<void> {
|
|
107
123
|
const line = resolveLine(args, 'updateChannelMeta');
|
|
124
|
+
const parsed = parseLine(line);
|
|
125
|
+
if (!parsed) throw new TrainError('INVALID_ARGS', `bad xmtp line: ${line}`);
|
|
126
|
+
const key = lineOf(parsed.accountId, parsed.convId);
|
|
108
127
|
const { name, description, appData } = args as {
|
|
109
128
|
name?: string;
|
|
110
129
|
description?: string;
|
|
111
130
|
appData?: Record<string, unknown>;
|
|
112
131
|
};
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
);
|
|
117
|
-
|
|
132
|
+
if (name !== undefined && typeof name !== 'string') {
|
|
133
|
+
throw new TrainError('INVALID_ARGS', 'name must be a string');
|
|
134
|
+
}
|
|
135
|
+
const task = () => applyChannelMeta({ line, name, description, appData }, 'updateChannelMeta');
|
|
136
|
+
const previous = metadataWrites.get(key);
|
|
137
|
+
const pending = previous ? previous.then(task, task) : task();
|
|
138
|
+
metadataWrites.set(key, pending);
|
|
139
|
+
try {
|
|
140
|
+
respond(id, { result: await pending });
|
|
141
|
+
} finally {
|
|
142
|
+
if (metadataWrites.get(key) === pending) metadataWrites.delete(key);
|
|
143
|
+
}
|
|
118
144
|
}
|
|
@@ -3,7 +3,7 @@ import { convOf, type Account } from './accounts.js';
|
|
|
3
3
|
import { inboxEthCache, cacheInboxEth } from './wire.js';
|
|
4
4
|
import { TrainError } from '@metro-labs/core/train-error';
|
|
5
5
|
import type { MemberList, MetroMember } from '@metro-labs/core/stations/types';
|
|
6
|
-
import { readAppData, type GroupLike } from './labels.js';
|
|
6
|
+
import { readAppData, readAppDataObject, type GroupLike } from './labels.js';
|
|
7
7
|
|
|
8
8
|
export { parseMemberArgs, resolveMembers } from './member-args.js';
|
|
9
9
|
|
|
@@ -78,6 +78,9 @@ export async function buildGroupInfo(
|
|
|
78
78
|
acct: Account,
|
|
79
79
|
conv: Conv,
|
|
80
80
|
): Promise<Record<string, unknown>> {
|
|
81
|
+
await conv.sync();
|
|
82
|
+
const rawAppData = (conv as unknown as GroupLike).appData ?? '';
|
|
83
|
+
const appData = readAppDataObject(rawAppData);
|
|
81
84
|
const inboxIds = (await conv.members()).map((m) => m.inboxId);
|
|
82
85
|
const addresses = await resolveAddresses(acct, inboxIds);
|
|
83
86
|
const isDm =
|
|
@@ -86,9 +89,7 @@ export async function buildGroupInfo(
|
|
|
86
89
|
const gn = (conv as unknown as { name?: string | (() => Promise<string>) })
|
|
87
90
|
.name;
|
|
88
91
|
const resolvedName = typeof gn === 'function' ? await gn() : (gn ?? '');
|
|
89
|
-
const { labels, github, preview } = readAppData(
|
|
90
|
-
(conv as unknown as GroupLike).appData,
|
|
91
|
-
);
|
|
92
|
+
const { labels, github, preview } = readAppData(rawAppData);
|
|
92
93
|
return {
|
|
93
94
|
line,
|
|
94
95
|
id: conv.id,
|
|
@@ -99,6 +100,9 @@ export async function buildGroupInfo(
|
|
|
99
100
|
labels,
|
|
100
101
|
github,
|
|
101
102
|
preview,
|
|
103
|
+
assigned: Object.hasOwn(appData, 'assigned') ? appData.assigned : [],
|
|
104
|
+
appData,
|
|
105
|
+
rawAppData,
|
|
102
106
|
members: inboxIds.map((iid) => ({
|
|
103
107
|
inboxId: iid,
|
|
104
108
|
address: addresses[iid] ?? null,
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { TrainError } from '@metro-labs/core/train-error';
|
|
2
|
+
|
|
1
3
|
export interface GroupLike {
|
|
2
4
|
id: string;
|
|
3
5
|
appData?: string;
|
|
@@ -10,6 +12,7 @@ export interface GroupLike {
|
|
|
10
12
|
|
|
11
13
|
const MAX_LABELS = 16;
|
|
12
14
|
const MAX_LABEL_LEN = 24;
|
|
15
|
+
const MAX_APP_DATA_BYTES = 8192;
|
|
13
16
|
|
|
14
17
|
function cleanLabels(raw: unknown): string[] {
|
|
15
18
|
if (!Array.isArray(raw)) return [];
|
|
@@ -27,18 +30,40 @@ function cleanLabels(raw: unknown): string[] {
|
|
|
27
30
|
return out;
|
|
28
31
|
}
|
|
29
32
|
|
|
30
|
-
function
|
|
33
|
+
export function readAppDataObject(
|
|
31
34
|
existingAppData: string | undefined,
|
|
32
35
|
): Record<string, unknown> {
|
|
33
36
|
if (!existingAppData?.trim()) return {};
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
37
|
+
const parsed: unknown = JSON.parse(existingAppData);
|
|
38
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
39
|
+
throw new TrainError('INVALID_ARGS', 'Channel appData must be a JSON object');
|
|
40
|
+
}
|
|
41
|
+
return parsed as Record<string, unknown>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function normalizeAssigned(value: unknown): string[] {
|
|
45
|
+
if (!Array.isArray(value)) {
|
|
46
|
+
throw new TrainError('INVALID_ARGS', 'assigned must be an array of Ethereum addresses');
|
|
47
|
+
}
|
|
48
|
+
const addresses: string[] = [];
|
|
49
|
+
for (const address of value) {
|
|
50
|
+
if (typeof address !== 'string' || !/^0x[0-9a-fA-F]{40}$/.test(address.trim())) {
|
|
51
|
+
throw new TrainError('INVALID_ARGS', 'assigned must contain valid Ethereum addresses');
|
|
52
|
+
}
|
|
53
|
+
addresses.push(address.trim().toLowerCase());
|
|
54
|
+
}
|
|
55
|
+
return [...new Set(addresses)];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function metadataPatch(value: unknown): Record<string, unknown> {
|
|
59
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
60
|
+
throw new TrainError('INVALID_ARGS', 'metadata must be a JSON object');
|
|
61
|
+
}
|
|
62
|
+
const patch = value as Record<string, unknown>;
|
|
63
|
+
if (Object.hasOwn(patch, 'v')) {
|
|
64
|
+
throw new TrainError('INVALID_ARGS', 'metadata cannot change the reserved v field');
|
|
40
65
|
}
|
|
41
|
-
return
|
|
66
|
+
return patch;
|
|
42
67
|
}
|
|
43
68
|
|
|
44
69
|
function trimmedString(v: unknown): string | undefined {
|
|
@@ -65,13 +90,24 @@ export function readAppData(appData: string | undefined): {
|
|
|
65
90
|
}
|
|
66
91
|
}
|
|
67
92
|
|
|
93
|
+
function checkedLabels(value: unknown): string[] {
|
|
94
|
+
if (!Array.isArray(value) || value.some((label: unknown) => typeof label !== 'string')) {
|
|
95
|
+
throw new TrainError('INVALID_ARGS', 'labels must be an array of strings');
|
|
96
|
+
}
|
|
97
|
+
return cleanLabels(value);
|
|
98
|
+
}
|
|
99
|
+
|
|
68
100
|
function applyMergeKey(
|
|
69
101
|
merged: Record<string, unknown>,
|
|
70
102
|
k: string,
|
|
71
103
|
v: unknown,
|
|
72
104
|
): void {
|
|
73
105
|
if (k === 'labels') {
|
|
74
|
-
merged.labels =
|
|
106
|
+
merged.labels = checkedLabels(v);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
if (k === 'assigned') {
|
|
110
|
+
merged.assigned = normalizeAssigned(v);
|
|
75
111
|
return;
|
|
76
112
|
}
|
|
77
113
|
if (k === 'github') {
|
|
@@ -87,19 +123,25 @@ function applyMergeKey(
|
|
|
87
123
|
return;
|
|
88
124
|
}
|
|
89
125
|
if (v === undefined || v === null) Reflect.deleteProperty(merged, k);
|
|
90
|
-
else merged
|
|
126
|
+
else Object.defineProperty(merged, k, { value: v, enumerable: true, configurable: true, writable: true });
|
|
91
127
|
}
|
|
92
128
|
|
|
93
129
|
export function mergeAppData(
|
|
94
130
|
existingAppData: string | undefined,
|
|
95
131
|
patch: Record<string, unknown>,
|
|
96
132
|
): { blob: string; merged: Record<string, unknown> } {
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
133
|
+
const current = readAppDataObject(existingAppData);
|
|
134
|
+
const checked = metadataPatch(patch);
|
|
135
|
+
if (Object.hasOwn(checked, 'assigned') && Object.hasOwn(current, 'assigned')) {
|
|
136
|
+
normalizeAssigned(current.assigned);
|
|
137
|
+
}
|
|
138
|
+
const merged: Record<string, unknown> = { v: 1, ...current };
|
|
139
|
+
for (const [k, v] of Object.entries(checked)) applyMergeKey(merged, k, v);
|
|
140
|
+
const blob = JSON.stringify(merged);
|
|
141
|
+
if (Buffer.byteLength(blob, 'utf8') > MAX_APP_DATA_BYTES) {
|
|
142
|
+
throw new TrainError('INVALID_ARGS', `Channel appData exceeds ${MAX_APP_DATA_BYTES} bytes`);
|
|
143
|
+
}
|
|
144
|
+
return { blob, merged };
|
|
103
145
|
}
|
|
104
146
|
|
|
105
147
|
export function normalizeGithubUrl(url: unknown): string {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { CanonicalAttachment, ToolContext } from '@metro-labs/core/stations/types';
|
|
2
2
|
import { TrainError } from '@metro-labs/core/train-error';
|
|
3
|
+
import { metadataPatch, mergeAppData } from './labels.js';
|
|
3
4
|
import {
|
|
4
5
|
guessMime,
|
|
5
6
|
isImageMime,
|
|
@@ -9,24 +10,34 @@ import {
|
|
|
9
10
|
|
|
10
11
|
export const str = (v: unknown): string => (typeof v === 'string' ? v : '');
|
|
11
12
|
|
|
13
|
+
function channelAppData(a: Record<string, unknown>): Record<string, unknown> {
|
|
14
|
+
const appData = a.metadata === undefined ? {} : { ...metadataPatch(a.metadata) };
|
|
15
|
+
for (const key of ['labels', 'github', 'preview']) {
|
|
16
|
+
if (!Object.hasOwn(a, key)) continue;
|
|
17
|
+
if (Object.hasOwn(appData, key)) {
|
|
18
|
+
throw new TrainError('INVALID_ARGS', `Specify ${key} only once, inside metadata or at the top level`);
|
|
19
|
+
}
|
|
20
|
+
appData[key] = a[key];
|
|
21
|
+
}
|
|
22
|
+
mergeAppData(undefined, appData);
|
|
23
|
+
return appData;
|
|
24
|
+
}
|
|
25
|
+
|
|
12
26
|
export async function setChannelMetadata(
|
|
13
27
|
a: Record<string, unknown>,
|
|
14
28
|
ctx: ToolContext,
|
|
15
29
|
) {
|
|
16
30
|
const line = str(a.line);
|
|
17
31
|
if (!line) return ctx.err('set_channel_metadata requires `line`');
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
const
|
|
23
|
-
if (Array.isArray(labels)) appData.labels = labels.map(String);
|
|
24
|
-
if (typeof github === 'string') appData.github = github;
|
|
25
|
-
if (typeof preview === 'string') appData.preview = preview;
|
|
32
|
+
if (Object.hasOwn(a, 'name') && typeof a.name !== 'string') {
|
|
33
|
+
return ctx.err('set_channel_metadata name must be a string');
|
|
34
|
+
}
|
|
35
|
+
const appData = channelAppData(a);
|
|
36
|
+
const metaName = a.name;
|
|
26
37
|
const hasName = typeof metaName === 'string' && metaName.length > 0;
|
|
27
38
|
if (!hasName && Object.keys(appData).length === 0)
|
|
28
39
|
return ctx.err(
|
|
29
|
-
'set_channel_metadata requires at least one of `
|
|
40
|
+
'set_channel_metadata requires at least one of `metadata`, `github`, `preview`, `name`, `labels`',
|
|
30
41
|
);
|
|
31
42
|
const callArgs: Record<string, unknown> = { line, appData };
|
|
32
43
|
if (hasName) callArgs.name = metaName;
|
|
@@ -115,7 +115,9 @@ export const XMTP_TOOLS: StationTool[] = [
|
|
|
115
115
|
description:
|
|
116
116
|
"Read an XMTP channel's current metadata + membership. Args: line (required). Returns " +
|
|
117
117
|
'{line, id, account, version (dm|group), name, memberCount, labels, github, preview, ' +
|
|
118
|
-
'
|
|
118
|
+
'assigned, appData (stored JSON object), rawAppData (stored JSON string), ' +
|
|
119
|
+
'members:[{inboxId, address}]}. Syncs first and fails if metadata cannot be read. ' +
|
|
120
|
+
'xmtp-only (daemon `groupInfo`). Use before ' +
|
|
119
121
|
'set_channel_metadata/add_members to see current state.',
|
|
120
122
|
inputSchema: {
|
|
121
123
|
type: 'object',
|
|
@@ -167,17 +169,36 @@ export const XMTP_TOOLS: StationTool[] = [
|
|
|
167
169
|
group: 'write',
|
|
168
170
|
description:
|
|
169
171
|
"Update an existing channel's metadata. Args: line (required, the metro:// line), and " +
|
|
170
|
-
'any of
|
|
171
|
-
'
|
|
172
|
-
'
|
|
173
|
-
'
|
|
172
|
+
'any of metadata? (JSON object), github? (url), preview? (url), name? (string). ' +
|
|
173
|
+
'metadata is a shallow appData patch: omitted keys are preserved, supplied nested ' +
|
|
174
|
+
'values replace that key, and null deletes custom keys. v is reserved. Put labels ' +
|
|
175
|
+
'and assigned inside metadata. assigned replaces the array with normalized lowercase ' +
|
|
176
|
+
'Ethereum addresses of current members. Read group_info and union existing IDs to ' +
|
|
177
|
+
'add assignees. labels remains a legacy top-level alias. Duplicate top-level/metadata ' +
|
|
178
|
+
'label/link aliases are refused. All fields use one updateChannelMeta call. xmtp-only. ' +
|
|
179
|
+
'Returns updated metadata. Reread group_info to verify persistence.',
|
|
174
180
|
inputSchema: {
|
|
175
181
|
type: 'object',
|
|
176
182
|
properties: {
|
|
177
183
|
line: lineProp,
|
|
184
|
+
metadata: {
|
|
185
|
+
type: 'object',
|
|
186
|
+
description: 'Shallow JSON appData patch. Omitted keys stay unchanged. v is reserved.',
|
|
187
|
+
properties: {
|
|
188
|
+
labels: { type: 'array', items: { type: 'string' } },
|
|
189
|
+
assigned: {
|
|
190
|
+
type: 'array',
|
|
191
|
+
description: 'Replacement assignee list. Current member Ethereum addresses only. [] clears.',
|
|
192
|
+
items: { type: 'string', pattern: '^0x[0-9a-fA-F]{40}$' },
|
|
193
|
+
},
|
|
194
|
+
github: { type: 'string' },
|
|
195
|
+
preview: { type: 'string' },
|
|
196
|
+
},
|
|
197
|
+
additionalProperties: true,
|
|
198
|
+
},
|
|
178
199
|
labels: {
|
|
179
200
|
type: 'array',
|
|
180
|
-
description: '
|
|
201
|
+
description: 'Legacy alias. Prefer metadata.labels.',
|
|
181
202
|
items: { type: 'string' },
|
|
182
203
|
},
|
|
183
204
|
github: {
|
package/runtime/runtime.json
CHANGED