@stage-labs/metro 0.1.0-beta.157 → 0.1.0-beta.158

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.157",
3
+ "version": "0.1.0-beta.158",
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.157",
3
+ "version": "0.1.0-beta.158",
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",
@@ -17,8 +17,8 @@ async function call(
17
17
  ): Promise<Response> {
18
18
  try {
19
19
  return await fetch(`${GATEWAY_API}${path}`, {
20
- ...init,
21
20
  signal: AbortSignal.timeout(TIMEOUT_MS),
21
+ ...init,
22
22
  });
23
23
  } catch (err) {
24
24
  throw new TrainError(
@@ -130,3 +130,23 @@ export async function sendE2E(
130
130
  );
131
131
  return messageId;
132
132
  }
133
+
134
+ const BLOB_ID_RE = /^[0-9a-f]{32}$/;
135
+ const BLOB_TIMEOUT_MS = 120_000;
136
+
137
+ export async function uploadBlob(c: GatewayCreds, data: Uint8Array): Promise<string> {
138
+ const form = new FormData();
139
+ form.append('blob', new Blob([Buffer.from(data)]), 'blob');
140
+ const res = await call(`/upload_blob?${authQuery(c)}`, { method: 'POST', body: form, signal: AbortSignal.timeout(BLOB_TIMEOUT_MS) }, 'upload_blob');
141
+ if (!res.ok) throw refusal(res.status, 'blob');
142
+ const blobId = (await res.text()).trim().toLowerCase();
143
+ if (!BLOB_ID_RE.test(blobId)) throw new TrainError('threema_gateway_error', 'upload_blob: the Threema Gateway answered without a blob id');
144
+ return blobId;
145
+ }
146
+
147
+ export async function downloadBlob(c: GatewayCreds, blobId: string): Promise<Uint8Array> {
148
+ if (!BLOB_ID_RE.test(blobId)) throw new TrainError('threema_bad_blob', `'${blobId}' is not a blob id`, { retryable: false });
149
+ const res = await call(`/blobs/${blobId}?${authQuery(c)}`, { signal: AbortSignal.timeout(BLOB_TIMEOUT_MS) }, 'blob download');
150
+ if (!res.ok) throw refusal(res.status, `blob ${blobId}`);
151
+ return new Uint8Array(await res.arrayBuffer());
152
+ }
@@ -90,3 +90,11 @@ export function unquote(text: string): { replyTo?: string; text: string } {
90
90
  const body = m?.[2];
91
91
  return replyTo === undefined || body === undefined ? { text } : { replyTo, text: body };
92
92
  }
93
+
94
+ const FILE_NONCE = new Uint8Array([...new Array<number>(23).fill(0), 1]);
95
+
96
+ export const newBlobKey = (): Uint8Array => nacl.randomBytes(nacl.secretbox.keyLength);
97
+
98
+ export const sealBlob = (data: Uint8Array, key: Uint8Array): Uint8Array => nacl.secretbox(data, FILE_NONCE, key);
99
+
100
+ export const openBlob = (data: Uint8Array, key: Uint8Array): Uint8Array | null => nacl.secretbox.open(data, FILE_NONCE, key);
@@ -0,0 +1,93 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { emit, mintId } from '@metro-labs/core/stations/station-runtime';
3
+ import { assertAttachmentSize, isImageMime, kindOf, saveBufferToCache } from '@metro-labs/core/stations/attachments';
4
+ import { TrainError } from '@metro-labs/core/train-error';
5
+ import type { Account } from './accounts.js';
6
+ import { downloadBlob, uploadBlob } from './api.js';
7
+ import { bytesToHex, hexToBytes, newBlobKey, openBlob, sealBlob } from './crypto.js';
8
+ import { emitInbound, textEnvelope, type InboundMeta, type Room } from './format.js';
9
+ import { encodeFile, encodeGroupFile, type FileData, type GroupRef } from './messages.js';
10
+
11
+ export const MAX_BLOB_BYTES = 50 * 1024 * 1024;
12
+ const SELF_URI = process.env.METRO_SELF_URI ?? '';
13
+
14
+ export interface OutgoingFile {
15
+ path: string;
16
+ mime: string;
17
+ name: string;
18
+ }
19
+
20
+ export async function packFile(acct: Account, file: OutgoingFile, caption: string | null): Promise<FileData> {
21
+ const data = await readFile(file.path);
22
+ assertAttachmentSize(data.length);
23
+ if (data.length > MAX_BLOB_BYTES) throw new TrainError('threema_file_too_big', `Threema carries files up to 50 MB; ${file.name} is bigger`, { retryable: false });
24
+ const key = newBlobKey();
25
+ const blobId = await uploadBlob(acct.cfg, sealBlob(new Uint8Array(data), key));
26
+ return { blobId, key: bytesToHex(key), mime: file.mime, name: file.name, size: data.length, caption, media: isImageMime(file.mime) };
27
+ }
28
+
29
+ export const encodeFileFor = (group: GroupRef | null, file: FileData): Uint8Array => (group === null ? encodeFile(file) : encodeGroupFile(group, file));
30
+
31
+ export const fileLabel = (file: OutgoingFile): string => kindOf(file.mime, file.path);
32
+
33
+ function savedEnvelope(accountId: string, line: string, forId: string, file: FileData, path: string, bytes: number): Record<string, unknown> {
34
+ return {
35
+ kind: 'inbound',
36
+ id: mintId(),
37
+ ts: new Date().toISOString(),
38
+ station: 'threema',
39
+ line,
40
+ from: SELF_URI,
41
+ text: `📎 saved: ${path}`,
42
+ payload: {
43
+ account: accountId,
44
+ contentType: 'attachmentSaved',
45
+ attachmentFor: forId,
46
+ index: 0,
47
+ kind: kindOf(file.mime, file.name),
48
+ attachmentPath: path,
49
+ localPath: path,
50
+ mime: file.mime,
51
+ name: file.name,
52
+ size: bytes,
53
+ },
54
+ };
55
+ }
56
+
57
+ function failedEnvelope(accountId: string, line: string, forId: string, file: FileData, reason: string): Record<string, unknown> {
58
+ return {
59
+ kind: 'inbound',
60
+ id: mintId(),
61
+ ts: new Date().toISOString(),
62
+ station: 'threema',
63
+ line,
64
+ from: SELF_URI,
65
+ text: `📎 not fetched: ${reason}`,
66
+ payload: { account: accountId, contentType: 'attachmentFailed', attachmentFor: forId, index: 0, kind: kindOf(file.mime, file.name), reason },
67
+ };
68
+ }
69
+
70
+ async function fetchFile(acct: Account, m: InboundMeta, file: FileData): Promise<{ path: string; bytes: number }> {
71
+ const sealed = await downloadBlob(acct.cfg, file.blobId);
72
+ const plain = openBlob(sealed, hexToBytes(file.key, 'blob key'));
73
+ if (plain === null) throw new TrainError('threema_bad_blob', 'the file did not decrypt with the key in the message', { retryable: false });
74
+ const saved = await saveBufferToCache(plain, m.messageId, 0, { mime: file.mime, name: file.name });
75
+ return { path: saved.path, bytes: saved.bytes };
76
+ }
77
+
78
+ export function deliverFile(acct: Account, m: InboundMeta, file: FileData, room: Room, sentByUs: (id: string) => boolean): void {
79
+ const env = textEnvelope(acct.cfg.id, m, file.caption ?? '', sentByUs, room);
80
+ const line = String(env.line);
81
+ const forId = String(env.id);
82
+ const payload = { ...(env.payload as Record<string, unknown>), attachments: [{ kind: kindOf(file.mime, file.name), name: file.name, mime: file.mime, ...(file.size === null ? {} : { size: file.size }) }] };
83
+ emitInbound(acct.cfg.id, acct.cfg.owner, { ...env, payload });
84
+ fetchFile(acct, m, file)
85
+ .then(({ path, bytes }) => {
86
+ emit(savedEnvelope(acct.cfg.id, line, forId, file, path, bytes));
87
+ })
88
+ .catch((err: unknown) => {
89
+ const reason = err instanceof Error ? err.message : String(err);
90
+ process.stderr.write(`threema[${acct.cfg.id}]: could not fetch ${file.name} from ${m.from}: ${reason}\n`);
91
+ emit(failedEnvelope(acct.cfg.id, line, forId, file, reason));
92
+ });
93
+ }
@@ -8,6 +8,7 @@ import {
8
8
  type InboundMeta,
9
9
  type Room,
10
10
  } from './format.js';
11
+ import { deliverFile } from './files.js';
11
12
  import type { Decoded, GroupRef } from './messages.js';
12
13
  import { requestSync, sentByUs } from './outbound.js';
13
14
 
@@ -54,10 +55,8 @@ function control(acct: Account, m: InboundMeta, d: Decoded): string | null {
54
55
  return null;
55
56
  }
56
57
 
57
- export function deliver(acct: Account, m: InboundMeta, d: Decoded): string {
58
+ function chat(acct: Account, m: InboundMeta, d: Decoded): string | null {
58
59
  const owner = acct.cfg.owner;
59
- const handled = control(acct, m, d);
60
- if (handled !== null) return handled;
61
60
  if (d.kind === 'text') {
62
61
  emitInbound(acct.cfg.id, owner, textEnvelope(acct.cfg.id, m, d.text, sentByUs));
63
62
  return 'text';
@@ -67,11 +66,22 @@ export function deliver(acct: Account, m: InboundMeta, d: Decoded): string {
67
66
  emitInbound(acct.cfg.id, owner, textEnvelope(acct.cfg.id, m, d.text, sentByUs, roomOf(acct, d.group)));
68
67
  return 'group-text';
69
68
  }
69
+ if (d.kind === 'file') {
70
+ if (d.group !== null) askForRoster(acct, d.group);
71
+ deliverFile(acct, m, d.file, roomOf(acct, d.group), sentByUs);
72
+ return 'file';
73
+ }
70
74
  if (d.kind === 'reaction') {
71
75
  if (d.group !== null) askForRoster(acct, d.group);
72
76
  emitInbound(acct.cfg.id, owner, reactionEnvelope(acct.cfg.id, m, d.emoji, d.messageId, d.removed, roomOf(acct, d.group)));
73
77
  return 'reaction';
74
78
  }
79
+ return null;
80
+ }
81
+
82
+ export function deliver(acct: Account, m: InboundMeta, d: Decoded): string {
83
+ const handled = control(acct, m, d) ?? chat(acct, m, d);
84
+ if (handled !== null) return handled;
75
85
  if (d.kind === 'receipt') return reactions(acct, m, d);
76
86
  if (d.kind === 'typing') return 'typing';
77
87
  const type = d.kind === 'other' ? `0x${d.type.toString(16)}` : d.kind;
@@ -1,7 +1,9 @@
1
1
  import { bytesToHex, hexToBytes } from './crypto.js';
2
2
 
3
3
  export const MSG_TEXT = 0x01;
4
+ export const MSG_FILE = 0x17;
4
5
  export const MSG_GROUP_TEXT = 0x41;
6
+ export const MSG_GROUP_FILE = 0x46;
5
7
  export const MSG_GROUP_SETUP = 0x4a;
6
8
  export const MSG_GROUP_RENAME = 0x4b;
7
9
  export const MSG_GROUP_LEAVE = 0x4c;
@@ -26,8 +28,19 @@ export interface GroupRef {
26
28
  groupId: string;
27
29
  }
28
30
 
31
+ export interface FileData {
32
+ blobId: string;
33
+ key: string;
34
+ mime: string;
35
+ name: string;
36
+ size: number | null;
37
+ caption: string | null;
38
+ media: boolean;
39
+ }
40
+
29
41
  export type Decoded =
30
42
  | { kind: 'text'; text: string }
43
+ | { kind: 'file'; group: GroupRef | null; file: FileData }
31
44
  | { kind: 'group-text'; group: GroupRef; text: string }
32
45
  | { kind: 'group-setup'; groupId: string; members: string[] }
33
46
  | { kind: 'group-rename'; groupId: string; name: string }
@@ -74,6 +87,61 @@ export const encodeText = (text: string): Uint8Array => typed(MSG_TEXT, Buffer.f
74
87
 
75
88
  export const encodeGroupText = (group: GroupRef, text: string): Uint8Array => typed(MSG_GROUP_TEXT, groupHeader(group), Buffer.from(text, 'utf8'));
76
89
 
90
+ const fileJson = (f: FileData): Uint8Array =>
91
+ Buffer.from(
92
+ JSON.stringify({
93
+ b: f.blobId,
94
+ k: f.key,
95
+ m: f.mime,
96
+ n: f.name,
97
+ ...(f.size === null ? {} : { s: f.size }),
98
+ i: f.media ? 1 : 0,
99
+ j: f.media ? 1 : 0,
100
+ ...(f.caption === null ? {} : { d: f.caption }),
101
+ }),
102
+ 'utf8',
103
+ );
104
+
105
+ export const encodeFile = (f: FileData): Uint8Array => typed(MSG_FILE, fileJson(f));
106
+
107
+ export const encodeGroupFile = (group: GroupRef, f: FileData): Uint8Array => typed(MSG_GROUP_FILE, groupHeader(group), fileJson(f));
108
+
109
+ const HEX_RE = /^[0-9a-f]+$/i;
110
+
111
+ const str = (v: unknown, fallback: string): string => (typeof v === 'string' && v !== '' ? v : fallback);
112
+ const num = (v: unknown): number | null => (typeof v === 'number' ? v : null);
113
+ const hexOf = (v: unknown, length: number): string | null => {
114
+ const text = typeof v === 'string' ? v.toLowerCase() : '';
115
+ return text.length === length && HEX_RE.test(text) ? text : null;
116
+ };
117
+
118
+ function parseJson(raw: Uint8Array): Record<string, unknown> | null {
119
+ try {
120
+ const parsed: unknown = JSON.parse(utf8(raw));
121
+ return typeof parsed === 'object' && parsed !== null ? (parsed as Record<string, unknown>) : null;
122
+ } catch {
123
+ return null;
124
+ }
125
+ }
126
+
127
+ export function decodeFileJson(raw: Uint8Array): FileData | null {
128
+ const o = parseJson(raw);
129
+ if (o === null) return null;
130
+ const blobId = hexOf(o.b, 32);
131
+ const key = hexOf(o.k, 64);
132
+ if (blobId === null || key === null) return null;
133
+ const rendering = num(o.j) ?? num(o.i) ?? 0;
134
+ return {
135
+ blobId,
136
+ key,
137
+ mime: str(o.m, 'application/octet-stream'),
138
+ name: str(o.n, 'file'),
139
+ size: num(o.s),
140
+ caption: typeof o.d === 'string' && o.d !== '' ? o.d : null,
141
+ media: rendering === 1,
142
+ };
143
+ }
144
+
77
145
  export const encodeGroupSyncRequest = (groupId: string): Uint8Array => typed(MSG_GROUP_REQUEST_SYNC, hexToBytes(groupId, 'group id'));
78
146
 
79
147
  function varint(n: number): Uint8Array {
@@ -154,13 +222,18 @@ function decodeGroup(type: number, body: Uint8Array): Decoded {
154
222
  if (group === null) return { kind: 'other', type };
155
223
  const rest = body.subarray(ID_BYTES * 2);
156
224
  if (type === MSG_GROUP_TEXT) return { kind: 'group-text', group, text: utf8(rest) };
225
+ if (type === MSG_GROUP_FILE) {
226
+ const file = decodeFileJson(rest);
227
+ return file === null ? { kind: 'other', type } : { kind: 'file', group, file };
228
+ }
157
229
  if (type === MSG_GROUP_LEAVE) return { kind: 'group-leave', group };
158
230
  if (type === MSG_GROUP_DELIVERY_RECEIPT) return { kind: 'receipt', group, status: rest[0] ?? 0, messageIds: ids(rest, 1) };
159
231
  const reaction = decodeReactionProto(rest);
160
232
  return reaction === null ? { kind: 'other', type } : { kind: 'reaction', group, ...reaction };
161
233
  }
162
234
 
163
- const GROUP_TYPES = new Set([MSG_GROUP_TEXT, MSG_GROUP_LEAVE, MSG_GROUP_DELIVERY_RECEIPT, MSG_GROUP_REACTION]);
235
+ const GROUP_TYPES = new Set([MSG_GROUP_TEXT, MSG_GROUP_FILE, MSG_GROUP_LEAVE, MSG_GROUP_DELIVERY_RECEIPT, MSG_GROUP_REACTION]);
236
+ const DIRECT_TYPES = new Set([MSG_TEXT, MSG_FILE, MSG_TYPING, MSG_DELIVERY_RECEIPT, MSG_REACTION]);
164
237
 
165
238
  function decodeControl(type: number, body: Uint8Array): Decoded {
166
239
  if (body.length < ID_BYTES) return { kind: 'other', type };
@@ -171,6 +244,10 @@ function decodeControl(type: number, body: Uint8Array): Decoded {
171
244
 
172
245
  function decodeDirect(type: number, body: Uint8Array): Decoded {
173
246
  if (type === MSG_TEXT) return { kind: 'text', text: utf8(body) };
247
+ if (type === MSG_FILE) {
248
+ const file = decodeFileJson(body);
249
+ return file === null ? { kind: 'other', type } : { kind: 'file', group: null, file };
250
+ }
174
251
  if (type === MSG_TYPING) return { kind: 'typing' };
175
252
  if (type === MSG_DELIVERY_RECEIPT) return { kind: 'receipt', group: null, status: body[0] ?? 0, messageIds: ids(body, 1) };
176
253
  const reaction = decodeReactionProto(body);
@@ -180,7 +257,7 @@ function decodeDirect(type: number, body: Uint8Array): Decoded {
180
257
  export function decode(plain: Uint8Array): Decoded {
181
258
  const type = plain[0] ?? -1;
182
259
  const body = plain.subarray(1);
183
- if (type === MSG_TEXT || type === MSG_TYPING || type === MSG_DELIVERY_RECEIPT || type === MSG_REACTION) return decodeDirect(type, body);
260
+ if (DIRECT_TYPES.has(type)) return decodeDirect(type, body);
184
261
  if (type === MSG_GROUP_SETUP || type === MSG_GROUP_RENAME) return decodeControl(type, body);
185
262
  if (GROUP_TYPES.has(type)) return decodeGroup(type, body);
186
263
  return { kind: 'other', type };
@@ -3,6 +3,7 @@ import { respond } from '@metro-labs/core/stations/station-runtime';
3
3
  import { publicKeyFor, targetOf, type Account, type Target } from './accounts.js';
4
4
  import { sendE2E } from './api.js';
5
5
  import { bytesToHex, quoted, seal } from './crypto.js';
6
+ import { encodeFileFor, fileLabel, packFile, type OutgoingFile } from './files.js';
6
7
  import { emitOutbound, emitOutboundReaction } from './format.js';
7
8
  import { MESSAGE_ID_RE } from './ids.js';
8
9
  import {
@@ -67,15 +68,34 @@ interface SendArgs {
67
68
  text?: unknown;
68
69
  replyTo?: unknown;
69
70
  account?: string;
71
+ attachments?: unknown;
70
72
  }
71
73
 
72
- function requireText(a: SendArgs): string {
74
+ const filesOf = (raw: unknown): OutgoingFile[] =>
75
+ (Array.isArray(raw) ? raw : [])
76
+ .map((a) => (a ?? {}) as Record<string, unknown>)
77
+ .filter((a) => typeof a.path === 'string' && a.path !== '')
78
+ .map((a) => ({ path: String(a.path), mime: typeof a.mime === 'string' && a.mime !== '' ? a.mime : 'application/octet-stream', name: typeof a.name === 'string' && a.name !== '' ? a.name : 'file' }));
79
+
80
+ function textOf(a: SendArgs, files: OutgoingFile[]): string {
73
81
  const text = typeof a.text === 'string' ? a.text : '';
74
- if (text === '')
75
- throw new TrainError('threema_text_required', 'threema carries text only; give some text to send', { retryable: false });
82
+ if (text === '' && files.length === 0)
83
+ throw new TrainError('threema_text_required', 'give some text or a file to send', { retryable: false });
76
84
  return text;
77
85
  }
78
86
 
87
+ async function sendFiles(acct: Account, target: Target, files: OutgoingFile[], caption: string | null): Promise<{ ids: string[]; labels: string[] }> {
88
+ const ids: string[] = [];
89
+ const labels: string[] = [];
90
+ for (const [i, file] of files.entries()) {
91
+ const packed = await packFile(acct, file, i === 0 ? caption : null);
92
+ const sent = await fanOut(acct, target, encodeFileFor(null, packed), (g) => encodeFileFor(g, packed));
93
+ ids.push(...sent);
94
+ labels.push(fileLabel(file));
95
+ }
96
+ return { ids, labels };
97
+ }
98
+
79
99
  function messageIdOf(raw: unknown, field: string): string {
80
100
  const id = typeof raw === 'string' ? raw.toLowerCase() : '';
81
101
  if (!MESSAGE_ID_RE.test(id))
@@ -83,18 +103,40 @@ function messageIdOf(raw: unknown, field: string): string {
83
103
  return id;
84
104
  }
85
105
 
86
- export async function send(id: string, args: Args): Promise<void> {
87
- const a = args as unknown as SendArgs;
88
- const text = requireText(a);
89
- const replyTo = a.replyTo === undefined || a.replyTo === null || a.replyTo === '' ? undefined : messageIdOf(a.replyTo, 'replyTo');
90
- const { acct, target } = targetOf(a.line, a.account);
106
+ const replyTargetOf = (raw: unknown): string | undefined => (raw === undefined || raw === null || raw === '' ? undefined : messageIdOf(raw, 'replyTo'));
107
+
108
+ function bodyOf(text: string, replyTo: string | undefined): string {
91
109
  const body = replyTo === undefined ? text : quoted(replyTo, text);
92
110
  if (Buffer.byteLength(body, 'utf8') > MAX_TEXT_BYTES)
93
111
  throw new TrainError('threema_message_too_long', `Threema carries at most ${MAX_TEXT_BYTES} bytes of text per message; split it up`, { retryable: false });
94
- const ids = await fanOut(acct, target, encodeText(body), (g) => encodeGroupText(g, body));
95
- const messageId = ids[0] ?? '';
96
- emitOutbound(acct.cfg.id, a.line, messageId, text, replyTo);
97
- respond(id, { result: { messageId, account: acct.cfg.id, ...(ids.length > 1 ? { messageIds: ids } : {}) } });
112
+ return body;
113
+ }
114
+
115
+ interface Sent {
116
+ ids: string[];
117
+ labels: string[];
118
+ }
119
+
120
+ const resultOf = (acct: Account, sent: Sent): Record<string, unknown> => ({
121
+ messageId: sent.ids[0] ?? '',
122
+ account: acct.cfg.id,
123
+ ...(sent.ids.length > 1 ? { messageIds: sent.ids } : {}),
124
+ ...(sent.labels.length > 0 ? { attachments: sent.labels } : {}),
125
+ });
126
+
127
+ export async function send(id: string, args: Args): Promise<void> {
128
+ const a = args as unknown as SendArgs;
129
+ const files = filesOf(a.attachments);
130
+ const text = textOf(a, files);
131
+ const replyTo = replyTargetOf(a.replyTo);
132
+ const { acct, target } = targetOf(a.line, a.account);
133
+ const body = bodyOf(text, replyTo);
134
+ const sent: Sent =
135
+ files.length === 0
136
+ ? { ids: await fanOut(acct, target, encodeText(body), (g) => encodeGroupText(g, body)), labels: [] }
137
+ : await sendFiles(acct, target, files, body === '' ? null : body);
138
+ emitOutbound(acct.cfg.id, a.line, sent.ids[0] ?? '', text === '' ? `[${sent.labels.join(', ')}]` : text, replyTo);
139
+ respond(id, { result: resultOf(acct, sent) });
98
140
  }
99
141
 
100
142
  interface ReactArgs {
@@ -5,6 +5,6 @@ export const threemaStation: Station = {
5
5
  hasAccounts: true,
6
6
  hasTrain: true,
7
7
  messageVerbs: new Set<Verb>(['send', 'reply', 'react', 'unreact']),
8
- attachmentMode: 'none',
8
+ attachmentMode: 'canonical',
9
9
  tools: [],
10
10
  };
@@ -1,3 +1,3 @@
1
1
  {
2
- "version": "0.1.0-beta.157"
2
+ "version": "0.1.0-beta.158"
3
3
  }