@stage-labs/metro 0.1.0-beta.225 → 0.1.0-beta.227

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stage-labs/metro",
3
- "version": "0.1.0-beta.225",
3
+ "version": "0.1.0-beta.227",
4
4
  "description": "The metro command line: run your agent on this machine, relaying its chat channels and connectors to Claude Code.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "metro",
3
- "version": "0.1.0-beta.225",
3
+ "version": "0.1.0-beta.227",
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, readAppData, type GroupLike } from './labels.js';
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 readAppData(group.appData);
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 group.sync?.().catch(() => undefined);
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
- const result = await applyChannelMeta(
114
- { line, name, description, appData },
115
- 'updateChannelMeta',
116
- );
117
- respond(id, { result });
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
  }
@@ -1,13 +1,18 @@
1
- import { ReactionAction, ReactionSchema, type Conversation } from '@xmtp/node-sdk';
1
+ import {
2
+ GroupMessageKind,
3
+ ReactionAction,
4
+ ReactionSchema,
5
+ type Conversation,
6
+ } from '@xmtp/node-sdk';
2
7
  import type { Reply } from '@xmtp/node-bindings';
3
8
  import {
4
9
  AttachmentCodec,
5
10
  type Attachment,
6
11
  } from '@xmtp/content-type-remote-attachment';
7
- import { convOf } from './accounts.js';
12
+ import { convOf, type Account } from './accounts.js';
8
13
  import { resolveMsgId } from './wire.js';
9
14
  import { emitOutbound } from './emit.js';
10
- import { PollCodec, buildPollContent } from './codecs.js';
15
+ import { PollCodec, buildPollContent, encodeDeleteMessage } from './codecs.js';
11
16
  import { convHandlers } from './actions-conv.js';
12
17
  import { messagingAliases } from '@metro-labs/core/stations/messaging-normalize';
13
18
  import { TrainError } from '@metro-labs/core/train-error';
@@ -106,6 +111,28 @@ async function reply(id: string, args: Args): Promise<void> {
106
111
  respond(id, { result: { messageId: sentId } });
107
112
  }
108
113
 
114
+ const UNDELETABLE = new Set(['deleteMessage', 'deletedMessage']);
115
+
116
+ function assertDeletable(acct: Account, conv: Conversation, messageId: string): void {
117
+ const msg = acct.client.conversations.getMessageById(messageId);
118
+ if (msg?.conversationId !== conv.id)
119
+ throw new TrainError('NOT_FOUND', `message ${messageId} is not in this conversation`);
120
+ if (msg.senderInboxId !== acct.inboxId)
121
+ throw badArgs(`message ${messageId} was not sent by this account; only your own messages can be deleted`);
122
+ if (msg.kind !== GroupMessageKind.Application || UNDELETABLE.has(msg.contentType?.typeId ?? ''))
123
+ throw badArgs(`message ${messageId} cannot be deleted`);
124
+ }
125
+
126
+ async function remove(id: string, args: Args): Promise<void> {
127
+ const { line, messageId } = args as { line: string; messageId: string };
128
+ const { acct, conv } = await convOf(line);
129
+ if (!conv) throw noConv(line);
130
+ const target = resolveMsgId(messageId);
131
+ assertDeletable(acct, conv, target);
132
+ const sentId = await conv.send(encodeDeleteMessage(target), { shouldPush: false });
133
+ respond(id, { result: { messageId: sentId } });
134
+ }
135
+
109
136
  async function sendAttachment(id: string, args: Args): Promise<void> {
110
137
  const { line, name, mime, dataB64 } = args as {
111
138
  line: string;
@@ -186,6 +213,7 @@ const handlers: Record<string, (id: string, args: Args) => Promise<void>> = {
186
213
  ask,
187
214
  react,
188
215
  reply,
216
+ delete: remove,
189
217
  sendAttachment,
190
218
  sendImage,
191
219
  ...convHandlers,
@@ -164,6 +164,33 @@ export const SignatureReferenceCodec =
164
164
  c.signature ? `[Signature] ${c.signature}` : '[Signature]',
165
165
  );
166
166
 
167
+ const ContentTypeDeleteMessage: ContentTypeId = {
168
+ authorityId: 'xmtp.org',
169
+ typeId: 'deleteMessage',
170
+ versionMajor: 1,
171
+ versionMinor: 0,
172
+ };
173
+
174
+ const varint = (value: number): number[] => {
175
+ const out: number[] = [];
176
+ let rest = value;
177
+ while (rest > 0x7f) {
178
+ out.push((rest & 0x7f) | 0x80);
179
+ rest = Math.floor(rest / 0x80);
180
+ }
181
+ out.push(rest);
182
+ return out;
183
+ };
184
+
185
+ export function encodeDeleteMessage(messageId: string): EncodedContent {
186
+ const id = new TextEncoder().encode(messageId);
187
+ return {
188
+ type: ContentTypeDeleteMessage,
189
+ parameters: {},
190
+ content: new Uint8Array([0x0a, ...varint(id.length), ...id]),
191
+ };
192
+ }
193
+
167
194
  export const CODECS = (): ContentCodec[] => [
168
195
  new PollCodec(),
169
196
  new SignatureRequestCodec(),
@@ -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 parseAppDataObject(
33
+ export function readAppDataObject(
31
34
  existingAppData: string | undefined,
32
35
  ): Record<string, unknown> {
33
36
  if (!existingAppData?.trim()) return {};
34
- try {
35
- const p: unknown = JSON.parse(existingAppData);
36
- if (p && typeof p === 'object' && !Array.isArray(p))
37
- return p as Record<string, unknown>;
38
- } catch {
39
- return {};
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 = cleanLabels(v);
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[k] = v;
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 merged: Record<string, unknown> = {
98
- ...parseAppDataObject(existingAppData),
99
- v: 1,
100
- };
101
- for (const [k, v] of Object.entries(patch)) applyMergeKey(merged, k, v);
102
- return { blob: JSON.stringify(merged), merged };
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 {
@@ -6,7 +6,7 @@ export const xmtpStation: Station = {
6
6
  name: 'xmtp',
7
7
  hasAccounts: true,
8
8
  hasTrain: true,
9
- messageVerbs: new Set<Verb>(['send', 'reply', 'react', 'unreact', 'read']),
9
+ messageVerbs: new Set<Verb>(['send', 'reply', 'react', 'unreact', 'delete', 'read']),
10
10
  groupOps: new Set<GroupOp>(['create_group', 'add_members', 'remove_members']),
11
11
  attachmentMode: 'native',
12
12
  profileFields: new Set<ProfileField>(PROFILE_FIELDS),
@@ -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
- const labels = a.labels as unknown[] | undefined;
19
- const github = a.github as string | undefined;
20
- const preview = a.preview as string | undefined;
21
- const metaName = a.name as string | undefined;
22
- const appData: Record<string, unknown> = {};
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 `labels`, `github`, `preview`, `name`',
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
- 'members:[{inboxId, address}]}. xmtp-only (daemon `groupInfo`). Use before ' +
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 labels? (string[]), github? (url), preview? (url), name? (string). All provided ' +
171
- 'fields go in one updateChannelMeta call: the name first, then the labels and links ' +
172
- 'merged into the group appData. xmtp-only ' +
173
- '(channel metadata lives on xmtp groups). Returns the updated channel info.',
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: 'Status labels to set.',
201
+ description: 'Legacy alias. Prefer metadata.labels.',
181
202
  items: { type: 'string' },
182
203
  },
183
204
  github: {
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.225"
2
+ "version": "0.1.0-beta.227"
3
3
  }