@markmnl/fmsg-mcp 0.1.0
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/LICENSE +21 -0
- package/README.md +155 -0
- package/dist/address.d.ts +18 -0
- package/dist/address.js +50 -0
- package/dist/auth.d.ts +21 -0
- package/dist/auth.js +84 -0
- package/dist/client/client.d.ts +83 -0
- package/dist/client/client.js +310 -0
- package/dist/client/index.d.ts +6 -0
- package/dist/client/index.js +5 -0
- package/dist/client/message-id.d.ts +19 -0
- package/dist/client/message-id.js +70 -0
- package/dist/client/redact.d.ts +8 -0
- package/dist/client/redact.js +25 -0
- package/dist/client/types.d.ts +126 -0
- package/dist/client/types.js +2 -0
- package/dist/client/ws.d.ts +6 -0
- package/dist/client/ws.js +25 -0
- package/dist/config.d.ts +31 -0
- package/dist/config.js +74 -0
- package/dist/context.d.ts +20 -0
- package/dist/context.js +17 -0
- package/dist/errors.d.ts +5 -0
- package/dist/errors.js +39 -0
- package/dist/http.d.ts +14 -0
- package/dist/http.js +112 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +137 -0
- package/dist/prompts.d.ts +2 -0
- package/dist/prompts.js +44 -0
- package/dist/public.d.ts +10 -0
- package/dist/public.js +10 -0
- package/dist/render.d.ts +27 -0
- package/dist/render.js +109 -0
- package/dist/resources.d.ts +3 -0
- package/dist/resources.js +37 -0
- package/dist/server.d.ts +9 -0
- package/dist/server.js +26 -0
- package/dist/thread.d.ts +42 -0
- package/dist/thread.js +176 -0
- package/dist/tools/common.d.ts +62 -0
- package/dist/tools/common.js +88 -0
- package/dist/tools/identity.d.ts +2 -0
- package/dist/tools/identity.js +58 -0
- package/dist/tools/list.d.ts +2 -0
- package/dist/tools/list.js +72 -0
- package/dist/tools/read.d.ts +2 -0
- package/dist/tools/read.js +202 -0
- package/dist/tools/send.d.ts +2 -0
- package/dist/tools/send.js +170 -0
- package/dist/tools/wait.d.ts +2 -0
- package/dist/tools/wait.js +96 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +5 -0
- package/dist/wait.d.ts +41 -0
- package/dist/wait.js +210 -0
- package/package.json +74 -0
- package/server.json +24 -0
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
import { normalizeFmsgAddress } from "../address.js";
|
|
2
|
+
import { normalizeMessageId, parseFmsgJson, stringifyWithIds } from "./message-id.js";
|
|
3
|
+
import { redactSecrets } from "./redact.js";
|
|
4
|
+
/** An HTTP error from the fmsg Web API, with the status and the host's own error text. */
|
|
5
|
+
export class FmsgHttpError extends Error {
|
|
6
|
+
status;
|
|
7
|
+
method;
|
|
8
|
+
path;
|
|
9
|
+
code;
|
|
10
|
+
constructor(message, status, method, path,
|
|
11
|
+
/** Machine-readable `code` from the body, when the host sends one (thread routes). */
|
|
12
|
+
code) {
|
|
13
|
+
super(message);
|
|
14
|
+
this.status = status;
|
|
15
|
+
this.method = method;
|
|
16
|
+
this.path = path;
|
|
17
|
+
this.code = code;
|
|
18
|
+
this.name = "FmsgHttpError";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function decodeJwtPayload(token) {
|
|
22
|
+
const parts = token.split(".");
|
|
23
|
+
if (parts.length !== 3 || !parts[1])
|
|
24
|
+
throw new Error("token exchange returned an invalid JWT");
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(Buffer.from(parts[1], "base64url").toString("utf8"));
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
throw new Error("token exchange returned an unreadable JWT payload");
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
async function readError(response) {
|
|
33
|
+
const raw = await response.text().catch(() => "");
|
|
34
|
+
if (!raw)
|
|
35
|
+
return { message: `HTTP ${response.status}` };
|
|
36
|
+
try {
|
|
37
|
+
const parsed = JSON.parse(raw);
|
|
38
|
+
const message = typeof parsed.error === "string" ? parsed.error : `HTTP ${response.status}`;
|
|
39
|
+
return typeof parsed.code === "string" ? { message, code: parsed.code } : { message };
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return { message: raw.slice(0, 300) };
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
function withId(message, id) {
|
|
46
|
+
return { ...message, id, terminal: message.terminal === true, reaction: message.reaction ?? null, reactions: message.reactions ?? [] };
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Client for the fmsg Web API (FMSG-003). Exchanges an `fmsgk_` API key for a
|
|
50
|
+
* short-lived JWT, refreshes it ahead of expiry, and retries once on 401.
|
|
51
|
+
*/
|
|
52
|
+
export class FmsgClient {
|
|
53
|
+
apiKey;
|
|
54
|
+
options;
|
|
55
|
+
apiUrl;
|
|
56
|
+
token;
|
|
57
|
+
tokenPromise;
|
|
58
|
+
constructor(apiUrl, apiKey, options = {}) {
|
|
59
|
+
this.apiKey = apiKey;
|
|
60
|
+
this.options = options;
|
|
61
|
+
this.apiUrl = apiUrl.replace(/\/+$/u, "");
|
|
62
|
+
if (!/^https?:\/\//u.test(this.apiUrl))
|
|
63
|
+
throw new Error("FMSG_API_URL must be an http(s) URL");
|
|
64
|
+
if (!apiKey.startsWith("fmsgk_"))
|
|
65
|
+
throw new Error("fmsg API key must start with fmsgk_");
|
|
66
|
+
}
|
|
67
|
+
get fetchImpl() {
|
|
68
|
+
return this.options.fetch ?? fetch;
|
|
69
|
+
}
|
|
70
|
+
/** The address this client acts as (from the JWT `sub`), exchanging the key if needed. */
|
|
71
|
+
async address() {
|
|
72
|
+
return (await this.getToken()).address;
|
|
73
|
+
}
|
|
74
|
+
async getToken(force = false) {
|
|
75
|
+
const margin = this.options.refreshMarginMs ?? 300_000;
|
|
76
|
+
if (!force && this.token && this.token.expiresAtMs - margin > Date.now())
|
|
77
|
+
return this.token;
|
|
78
|
+
if (!force && this.tokenPromise)
|
|
79
|
+
return this.tokenPromise;
|
|
80
|
+
this.tokenPromise = this.exchangeToken();
|
|
81
|
+
try {
|
|
82
|
+
this.token = await this.tokenPromise;
|
|
83
|
+
return this.token;
|
|
84
|
+
}
|
|
85
|
+
finally {
|
|
86
|
+
this.tokenPromise = undefined;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async exchangeToken() {
|
|
90
|
+
const response = await this.fetchImpl(`${this.apiUrl}/fmsg/token`, {
|
|
91
|
+
method: "POST",
|
|
92
|
+
headers: { authorization: `Bearer ${this.apiKey}` },
|
|
93
|
+
signal: AbortSignal.timeout(this.options.timeoutMs ?? 60_000),
|
|
94
|
+
});
|
|
95
|
+
if (!response.ok) {
|
|
96
|
+
const { message } = await readError(response);
|
|
97
|
+
throw new FmsgHttpError(`token exchange failed: ${redactSecrets(message).text}`, response.status, "POST", "/fmsg/token");
|
|
98
|
+
}
|
|
99
|
+
const body = (await response.json());
|
|
100
|
+
if (typeof body.access_token !== "string")
|
|
101
|
+
throw new Error("token response has no access_token");
|
|
102
|
+
const payload = decodeJwtPayload(body.access_token);
|
|
103
|
+
const address = typeof payload.sub === "string" ? normalizeFmsgAddress(payload.sub) : undefined;
|
|
104
|
+
if (!address)
|
|
105
|
+
throw new Error("token JWT sub is not an fmsg address");
|
|
106
|
+
const fromResponse = typeof body.expires_at === "string" ? Date.parse(body.expires_at) : Number.NaN;
|
|
107
|
+
const fromJwt = typeof payload.exp === "number" ? payload.exp * 1000 : Number.NaN;
|
|
108
|
+
const fromIn = typeof body.expires_in === "number" ? Date.now() + body.expires_in * 1000 : Number.NaN;
|
|
109
|
+
const expiresAtMs = [fromResponse, fromJwt, fromIn].find(Number.isFinite) ?? Date.now() + 3_600_000;
|
|
110
|
+
return { accessToken: body.access_token, address, expiresAtMs };
|
|
111
|
+
}
|
|
112
|
+
async request(path, init = {}, retry401 = true) {
|
|
113
|
+
const token = await this.getToken();
|
|
114
|
+
const headers = new Headers(init.headers);
|
|
115
|
+
headers.set("authorization", `Bearer ${token.accessToken}`);
|
|
116
|
+
const signal = init.signal ?? AbortSignal.timeout(this.options.timeoutMs ?? 60_000);
|
|
117
|
+
const response = await this.fetchImpl(`${this.apiUrl}${path}`, { ...init, headers, signal });
|
|
118
|
+
if (response.status === 401 && retry401) {
|
|
119
|
+
await this.getToken(true);
|
|
120
|
+
return this.request(path, init, false);
|
|
121
|
+
}
|
|
122
|
+
if (!response.ok) {
|
|
123
|
+
const { message, code } = await readError(response);
|
|
124
|
+
const method = init.method ?? "GET";
|
|
125
|
+
throw new FmsgHttpError(redactSecrets(message).text, response.status, method, path, code);
|
|
126
|
+
}
|
|
127
|
+
return response;
|
|
128
|
+
}
|
|
129
|
+
async json(path, init) {
|
|
130
|
+
const response = await this.request(path, init);
|
|
131
|
+
return parseFmsgJson(await response.text());
|
|
132
|
+
}
|
|
133
|
+
// ── Messages ──────────────────────────────────────────────────────────
|
|
134
|
+
async listInbox(limit = 20, offset = 0, signal) {
|
|
135
|
+
const items = await this.json(`/fmsg?limit=${limit}&offset=${offset}`, { signal });
|
|
136
|
+
if (!Array.isArray(items))
|
|
137
|
+
throw new Error("inbox response is not an array");
|
|
138
|
+
return items.map((m) => withId(m, normalizeMessageId(m.id)));
|
|
139
|
+
}
|
|
140
|
+
async listSent(limit = 20, offset = 0, signal) {
|
|
141
|
+
const items = await this.json(`/fmsg/sent?limit=${limit}&offset=${offset}`, { signal });
|
|
142
|
+
if (!Array.isArray(items))
|
|
143
|
+
throw new Error("sent response is not an array");
|
|
144
|
+
return items.map((m) => withId(m, normalizeMessageId(m.id)));
|
|
145
|
+
}
|
|
146
|
+
async getMessage(id, signal) {
|
|
147
|
+
const mid = normalizeMessageId(id);
|
|
148
|
+
const message = await this.json(`/fmsg/${encodeURIComponent(mid)}`, { signal });
|
|
149
|
+
return withId(message, mid);
|
|
150
|
+
}
|
|
151
|
+
/** Raw message body bytes. */
|
|
152
|
+
async getData(id, signal) {
|
|
153
|
+
const mid = normalizeMessageId(id);
|
|
154
|
+
const response = await this.request(`/fmsg/${encodeURIComponent(mid)}/data`, { signal });
|
|
155
|
+
const contentType = response.headers.get("content-type") ?? undefined;
|
|
156
|
+
return { data: new Uint8Array(await response.arrayBuffer()), ...(contentType ? { contentType } : {}) };
|
|
157
|
+
}
|
|
158
|
+
/** Whether `short_text` already holds the complete body. */
|
|
159
|
+
static shortTextIsComplete(message) {
|
|
160
|
+
if (typeof message.short_text !== "string")
|
|
161
|
+
return false;
|
|
162
|
+
if (typeof message.size !== "number")
|
|
163
|
+
return false;
|
|
164
|
+
return Buffer.byteLength(message.short_text, "utf8") >= message.size;
|
|
165
|
+
}
|
|
166
|
+
static isText(message) {
|
|
167
|
+
const type = (message.type ?? "").toLowerCase();
|
|
168
|
+
return type.startsWith("text/") || type.startsWith("application/json") || /\+json\b/u.test(type);
|
|
169
|
+
}
|
|
170
|
+
/** Full body text for text-like messages; null for binary bodies. */
|
|
171
|
+
async getText(message, signal) {
|
|
172
|
+
if (!FmsgClient.isText(message))
|
|
173
|
+
return null;
|
|
174
|
+
if (FmsgClient.shortTextIsComplete(message))
|
|
175
|
+
return message.short_text ?? "";
|
|
176
|
+
if (message.size === 0)
|
|
177
|
+
return "";
|
|
178
|
+
const { data } = await this.getData(message.id, signal);
|
|
179
|
+
return Buffer.from(data).toString("utf8");
|
|
180
|
+
}
|
|
181
|
+
async getThreadMessages(id, signal) {
|
|
182
|
+
const mid = normalizeMessageId(id);
|
|
183
|
+
const thread = await this.json(`/fmsg/${encodeURIComponent(mid)}/thread/messages`, { signal });
|
|
184
|
+
return {
|
|
185
|
+
...thread,
|
|
186
|
+
root_id: normalizeMessageId(thread.root_id, "root_id"),
|
|
187
|
+
trigger_id: normalizeMessageId(thread.trigger_id, "trigger_id"),
|
|
188
|
+
messages: (thread.messages ?? []).map((m) => ({ ...m, id: normalizeMessageId(m.id) })),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
async getThreadText(id, signal) {
|
|
192
|
+
const mid = normalizeMessageId(id);
|
|
193
|
+
const response = await this.request(`/fmsg/${encodeURIComponent(mid)}/thread`, { signal });
|
|
194
|
+
return response.text();
|
|
195
|
+
}
|
|
196
|
+
/** Download by a `download` path returned from thread/messages (`/fmsg/...`). */
|
|
197
|
+
async downloadPath(path, signal) {
|
|
198
|
+
if (!path.startsWith("/fmsg/") || path.includes("://"))
|
|
199
|
+
throw new Error(`refusing to download non-fmsg path ${path}`);
|
|
200
|
+
const response = await this.request(path, { signal });
|
|
201
|
+
const contentType = response.headers.get("content-type") ?? undefined;
|
|
202
|
+
return { data: new Uint8Array(await response.arrayBuffer()), ...(contentType ? { contentType } : {}) };
|
|
203
|
+
}
|
|
204
|
+
async markRead(id, signal) {
|
|
205
|
+
const mid = normalizeMessageId(id);
|
|
206
|
+
const result = await this.json(`/fmsg/${encodeURIComponent(mid)}/read`, {
|
|
207
|
+
method: "POST",
|
|
208
|
+
signal,
|
|
209
|
+
});
|
|
210
|
+
return { id: mid, time_read: result.time_read ?? null };
|
|
211
|
+
}
|
|
212
|
+
async addRecipients(id, addTo, signal) {
|
|
213
|
+
const mid = normalizeMessageId(id);
|
|
214
|
+
const result = await this.json(`/fmsg/${encodeURIComponent(mid)}/add-to`, {
|
|
215
|
+
method: "POST",
|
|
216
|
+
headers: { "content-type": "application/json" },
|
|
217
|
+
body: JSON.stringify({ add_to: addTo }),
|
|
218
|
+
signal,
|
|
219
|
+
});
|
|
220
|
+
return { id: mid, added: result.added ?? addTo.length };
|
|
221
|
+
}
|
|
222
|
+
async react(id, emoji, signal) {
|
|
223
|
+
const mid = normalizeMessageId(id);
|
|
224
|
+
const result = await this.json(`/fmsg/${encodeURIComponent(mid)}/react`, {
|
|
225
|
+
method: "POST",
|
|
226
|
+
headers: { "content-type": "application/json" },
|
|
227
|
+
body: JSON.stringify({ emoji: emoji ?? "" }),
|
|
228
|
+
signal,
|
|
229
|
+
});
|
|
230
|
+
return {
|
|
231
|
+
id: result.id === undefined || result.id === null ? null : normalizeMessageId(result.id),
|
|
232
|
+
time: result.time ?? null,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
async downloadAttachment(id, filename, signal) {
|
|
236
|
+
const mid = normalizeMessageId(id);
|
|
237
|
+
const response = await this.request(`/fmsg/${encodeURIComponent(mid)}/attach/${encodeURIComponent(filename)}`, { signal });
|
|
238
|
+
const contentType = response.headers.get("content-type") ?? undefined;
|
|
239
|
+
return { data: new Uint8Array(await response.arrayBuffer()), ...(contentType ? { contentType } : {}) };
|
|
240
|
+
}
|
|
241
|
+
async deleteMessage(id, signal) {
|
|
242
|
+
const mid = normalizeMessageId(id);
|
|
243
|
+
await this.request(`/fmsg/${encodeURIComponent(mid)}`, { method: "DELETE", signal });
|
|
244
|
+
}
|
|
245
|
+
// ── Sending ───────────────────────────────────────────────────────────
|
|
246
|
+
async createDraft(input, from) {
|
|
247
|
+
const body = {
|
|
248
|
+
version: 1,
|
|
249
|
+
from,
|
|
250
|
+
to: input.to,
|
|
251
|
+
type: input.type ?? "text/markdown; charset=utf-8",
|
|
252
|
+
data: input.body,
|
|
253
|
+
topic: input.pid ? "" : (input.topic ?? ""),
|
|
254
|
+
...(input.important ? { important: true } : {}),
|
|
255
|
+
...(input.noReply ? { no_reply: true } : {}),
|
|
256
|
+
};
|
|
257
|
+
const serialized = input.pid ? stringifyWithIds(body, { pid: input.pid }) : JSON.stringify(body);
|
|
258
|
+
const response = await this.request("/fmsg", {
|
|
259
|
+
method: "POST",
|
|
260
|
+
headers: { "content-type": "application/json" },
|
|
261
|
+
body: serialized,
|
|
262
|
+
...(input.signal ? { signal: input.signal } : {}),
|
|
263
|
+
});
|
|
264
|
+
const result = parseFmsgJson(await response.text());
|
|
265
|
+
if (result.id === undefined || result.id === null)
|
|
266
|
+
throw new Error("draft response has no id");
|
|
267
|
+
return normalizeMessageId(result.id);
|
|
268
|
+
}
|
|
269
|
+
async uploadAttachment(draftId, attachment, signal) {
|
|
270
|
+
const form = new FormData();
|
|
271
|
+
const blob = new Blob([Buffer.from(attachment.data)], { type: attachment.contentType ?? "application/octet-stream" });
|
|
272
|
+
form.append("file", blob, attachment.filename);
|
|
273
|
+
const response = await this.request(`/fmsg/${encodeURIComponent(draftId)}/attach`, {
|
|
274
|
+
method: "POST",
|
|
275
|
+
body: form,
|
|
276
|
+
...(signal ? { signal } : {}),
|
|
277
|
+
});
|
|
278
|
+
const result = parseFmsgJson(await response.text());
|
|
279
|
+
return { filename: result.filename ?? attachment.filename, size: result.size ?? attachment.data.byteLength };
|
|
280
|
+
}
|
|
281
|
+
/** Draft → attach → send. The draft is deleted if any step after creation fails. */
|
|
282
|
+
async send(input) {
|
|
283
|
+
if (input.to.length === 0)
|
|
284
|
+
throw new Error("at least one recipient is required");
|
|
285
|
+
if (input.pid && input.topic)
|
|
286
|
+
throw new Error("a reply (pid) cannot carry a topic");
|
|
287
|
+
const from = await this.address();
|
|
288
|
+
const draftId = await this.createDraft(input, from);
|
|
289
|
+
try {
|
|
290
|
+
const attachments = [];
|
|
291
|
+
for (const attachment of input.attachments ?? []) {
|
|
292
|
+
attachments.push(await this.uploadAttachment(draftId, attachment, input.signal));
|
|
293
|
+
}
|
|
294
|
+
const response = await this.request(`/fmsg/${encodeURIComponent(draftId)}/send`, {
|
|
295
|
+
method: "POST",
|
|
296
|
+
...(input.signal ? { signal: input.signal } : {}),
|
|
297
|
+
});
|
|
298
|
+
const result = parseFmsgJson(await response.text());
|
|
299
|
+
return {
|
|
300
|
+
id: result.id === undefined || result.id === null ? draftId : normalizeMessageId(result.id),
|
|
301
|
+
time: result.time ?? null,
|
|
302
|
+
attachments,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
catch (error) {
|
|
306
|
+
await this.deleteMessage(draftId).catch(() => undefined);
|
|
307
|
+
throw error;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { FmsgClient, FmsgHttpError, type FmsgClientOptions } from "./client.js";
|
|
2
|
+
export { openFmsgWebSocket, parseWsEvent } from "./ws.js";
|
|
3
|
+
export { redactSecrets, safeErrorMessage, type Redacted } from "./redact.js";
|
|
4
|
+
export { normalizeMessageId, compareMessageIds, maxMessageId, parseFmsgJson, stringifyWithIds } from "./message-id.js";
|
|
5
|
+
export { normalizeFmsgAddress, isFmsgAddress } from "../address.js";
|
|
6
|
+
export type * from "./types.js";
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { FmsgClient, FmsgHttpError } from "./client.js";
|
|
2
|
+
export { openFmsgWebSocket, parseWsEvent } from "./ws.js";
|
|
3
|
+
export { redactSecrets, safeErrorMessage } from "./redact.js";
|
|
4
|
+
export { normalizeMessageId, compareMessageIds, maxMessageId, parseFmsgJson, stringifyWithIds } from "./message-id.js";
|
|
5
|
+
export { normalizeFmsgAddress, isFmsgAddress } from "../address.js";
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fmsg message ids are int64 values serialised as bare JSON numbers. JavaScript
|
|
3
|
+
* numbers lose precision above 2^53, so ids are kept as decimal strings and
|
|
4
|
+
* only ever cross the JSON boundary through the helpers below.
|
|
5
|
+
*/
|
|
6
|
+
/** Validate and normalise an id (number or digit string) to its decimal string form. */
|
|
7
|
+
export declare function normalizeMessageId(value: unknown, label?: string): string;
|
|
8
|
+
export declare function compareMessageIds(a: string, b: string): number;
|
|
9
|
+
export declare function maxMessageId(ids: Iterable<string>): string | undefined;
|
|
10
|
+
/**
|
|
11
|
+
* Parse fmsg JSON, converting id fields to exact decimal strings using the
|
|
12
|
+
* reviver's `context.source` (the original number text, Node 21+).
|
|
13
|
+
*/
|
|
14
|
+
export declare function parseFmsgJson<T = unknown>(text: string): T;
|
|
15
|
+
/**
|
|
16
|
+
* Serialise an object, emitting the given id fields as bare int64 numbers.
|
|
17
|
+
* `ids` values must already be normalised decimal strings.
|
|
18
|
+
*/
|
|
19
|
+
export declare function stringifyWithIds(value: Record<string, unknown>, ids: Record<string, string>): string;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* fmsg message ids are int64 values serialised as bare JSON numbers. JavaScript
|
|
3
|
+
* numbers lose precision above 2^53, so ids are kept as decimal strings and
|
|
4
|
+
* only ever cross the JSON boundary through the helpers below.
|
|
5
|
+
*/
|
|
6
|
+
const MAX_INT64 = 9223372036854775807n;
|
|
7
|
+
/** Validate and normalise an id (number or digit string) to its decimal string form. */
|
|
8
|
+
export function normalizeMessageId(value, label = "message id") {
|
|
9
|
+
let raw;
|
|
10
|
+
if (typeof value === "string")
|
|
11
|
+
raw = value.trim();
|
|
12
|
+
else if (typeof value === "number" && Number.isSafeInteger(value))
|
|
13
|
+
raw = String(value);
|
|
14
|
+
else if (typeof value === "bigint")
|
|
15
|
+
raw = value.toString();
|
|
16
|
+
else
|
|
17
|
+
throw new Error(`invalid ${label}: ${String(value)}`);
|
|
18
|
+
if (!/^[0-9]+$/u.test(raw))
|
|
19
|
+
throw new Error(`invalid ${label}: ${raw}`);
|
|
20
|
+
const big = BigInt(raw);
|
|
21
|
+
if (big < 1n || big > MAX_INT64)
|
|
22
|
+
throw new Error(`out-of-range ${label}: ${raw}`);
|
|
23
|
+
return big.toString();
|
|
24
|
+
}
|
|
25
|
+
export function compareMessageIds(a, b) {
|
|
26
|
+
const x = BigInt(a);
|
|
27
|
+
const y = BigInt(b);
|
|
28
|
+
return x < y ? -1 : x > y ? 1 : 0;
|
|
29
|
+
}
|
|
30
|
+
export function maxMessageId(ids) {
|
|
31
|
+
let best;
|
|
32
|
+
for (const id of ids)
|
|
33
|
+
if (best === undefined || compareMessageIds(id, best) > 0)
|
|
34
|
+
best = id;
|
|
35
|
+
return best;
|
|
36
|
+
}
|
|
37
|
+
/** JSON keys whose numeric values are int64 ids on the fmsg wire. */
|
|
38
|
+
const ID_KEYS = new Set(["id", "pid", "batch_id", "root_id", "trigger_id"]);
|
|
39
|
+
/**
|
|
40
|
+
* Parse fmsg JSON, converting id fields to exact decimal strings using the
|
|
41
|
+
* reviver's `context.source` (the original number text, Node 21+).
|
|
42
|
+
*/
|
|
43
|
+
export function parseFmsgJson(text) {
|
|
44
|
+
const parse = JSON.parse;
|
|
45
|
+
return parse(text, function (key, value, context) {
|
|
46
|
+
if (ID_KEYS.has(key) && typeof value === "number") {
|
|
47
|
+
const source = context?.source;
|
|
48
|
+
if (source && /^[0-9]+$/u.test(source))
|
|
49
|
+
return source;
|
|
50
|
+
return normalizeMessageId(value, key);
|
|
51
|
+
}
|
|
52
|
+
return value;
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Serialise an object, emitting the given id fields as bare int64 numbers.
|
|
57
|
+
* `ids` values must already be normalised decimal strings.
|
|
58
|
+
*/
|
|
59
|
+
export function stringifyWithIds(value, ids) {
|
|
60
|
+
const entries = Object.entries(ids).filter(([, v]) => v !== undefined);
|
|
61
|
+
if (entries.length === 0)
|
|
62
|
+
return JSON.stringify(value);
|
|
63
|
+
for (const [k] of entries) {
|
|
64
|
+
if (Object.hasOwn(value, k))
|
|
65
|
+
throw new Error(`JSON already contains ${k}`);
|
|
66
|
+
}
|
|
67
|
+
const base = JSON.stringify(value);
|
|
68
|
+
const extra = entries.map(([k, v]) => `${JSON.stringify(k)}:${normalizeMessageId(v, k)}`).join(",");
|
|
69
|
+
return base === "{}" ? `{${extra}}` : `${base.slice(0, -1)},${extra}}`;
|
|
70
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export type Redacted = {
|
|
2
|
+
text: string;
|
|
3
|
+
count: number;
|
|
4
|
+
};
|
|
5
|
+
/** Replace secrets with placeholders and report how many were replaced. */
|
|
6
|
+
export declare function redactSecrets(text: string): Redacted;
|
|
7
|
+
/** One-line, secret-free rendering of an error for logs and tool results. */
|
|
8
|
+
export declare function safeErrorMessage(error: unknown): string;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/** Patterns for secrets that must never leave the process in message bodies or logs. */
|
|
2
|
+
const PATTERNS = [
|
|
3
|
+
[/\bfmsgk_[A-Za-z0-9+/=._~-]{6,}\b/gu, "[REDACTED_FMSG_API_KEY]"],
|
|
4
|
+
[/\beyJ[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\b/gu, "[REDACTED_JWT]"],
|
|
5
|
+
[/\b(?:ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36,}\b|\bgithub_pat_[A-Za-z0-9_]{22,}\b/gu, "[REDACTED_GITHUB_TOKEN]"],
|
|
6
|
+
[/\bsk-[A-Za-z0-9_-]{20,}\b/gu, "[REDACTED_API_KEY]"],
|
|
7
|
+
[/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/gu, "[REDACTED_PRIVATE_KEY]"],
|
|
8
|
+
];
|
|
9
|
+
/** Replace secrets with placeholders and report how many were replaced. */
|
|
10
|
+
export function redactSecrets(text) {
|
|
11
|
+
let count = 0;
|
|
12
|
+
let out = text;
|
|
13
|
+
for (const [pattern, replacement] of PATTERNS) {
|
|
14
|
+
out = out.replace(pattern, () => {
|
|
15
|
+
count += 1;
|
|
16
|
+
return replacement;
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
return { text: out, count };
|
|
20
|
+
}
|
|
21
|
+
/** One-line, secret-free rendering of an error for logs and tool results. */
|
|
22
|
+
export function safeErrorMessage(error) {
|
|
23
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
24
|
+
return redactSecrets(message).text.replace(/[\r\n\u2028\u2029]+/gu, " ").slice(0, 2000);
|
|
25
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/** Wire types for the fmsg Web API (FMSG-003). Ids are decimal strings. */
|
|
2
|
+
export type RecipientDelivery = {
|
|
3
|
+
addr: string;
|
|
4
|
+
/** RFC3339 UTC; null until delivered. */
|
|
5
|
+
time_delivered: string | null;
|
|
6
|
+
/** The receiving host's response code for the last delivery attempt; null if none yet. */
|
|
7
|
+
response_code: number | null;
|
|
8
|
+
};
|
|
9
|
+
export type AddToBatch = {
|
|
10
|
+
batch_id?: string;
|
|
11
|
+
add_to_from?: string;
|
|
12
|
+
to?: string[];
|
|
13
|
+
to_delivery?: RecipientDelivery[];
|
|
14
|
+
time?: number | null;
|
|
15
|
+
};
|
|
16
|
+
export type Attachment = {
|
|
17
|
+
filename: string;
|
|
18
|
+
size: number;
|
|
19
|
+
};
|
|
20
|
+
export type ReactionGroup = {
|
|
21
|
+
emoji: string;
|
|
22
|
+
from: string[];
|
|
23
|
+
};
|
|
24
|
+
export type FmsgMessage = {
|
|
25
|
+
/** Present on list items and WebSocket events; absent on GET /fmsg/:id (filled in by the client). */
|
|
26
|
+
id: string;
|
|
27
|
+
version?: number;
|
|
28
|
+
has_pid?: boolean;
|
|
29
|
+
has_add_to?: boolean;
|
|
30
|
+
important?: boolean;
|
|
31
|
+
no_reply?: boolean;
|
|
32
|
+
deflate?: boolean;
|
|
33
|
+
terminal?: boolean;
|
|
34
|
+
pid?: string | null;
|
|
35
|
+
from: string;
|
|
36
|
+
to: string[];
|
|
37
|
+
to_delivery?: RecipientDelivery[];
|
|
38
|
+
add_to?: AddToBatch[];
|
|
39
|
+
/** POSIX seconds; null for drafts. */
|
|
40
|
+
time?: number | null;
|
|
41
|
+
topic?: string;
|
|
42
|
+
type?: string;
|
|
43
|
+
size?: number;
|
|
44
|
+
short_text?: string;
|
|
45
|
+
read?: boolean;
|
|
46
|
+
time_read?: number | null;
|
|
47
|
+
attachments?: Attachment[];
|
|
48
|
+
/** Non-null when this message is itself a reaction. */
|
|
49
|
+
reaction?: string | null;
|
|
50
|
+
reactions?: ReactionGroup[];
|
|
51
|
+
};
|
|
52
|
+
export type ThreadBody = {
|
|
53
|
+
type: string;
|
|
54
|
+
size: number;
|
|
55
|
+
text?: string;
|
|
56
|
+
download?: string;
|
|
57
|
+
cache_key?: string;
|
|
58
|
+
cacheable?: boolean;
|
|
59
|
+
};
|
|
60
|
+
export type ThreadAttachment = {
|
|
61
|
+
position: number;
|
|
62
|
+
type: string;
|
|
63
|
+
filename: string;
|
|
64
|
+
size: number;
|
|
65
|
+
download?: string;
|
|
66
|
+
cache_key?: string;
|
|
67
|
+
cacheable?: boolean;
|
|
68
|
+
};
|
|
69
|
+
export type ThreadMessage = {
|
|
70
|
+
id: string;
|
|
71
|
+
visible: boolean;
|
|
72
|
+
version?: number;
|
|
73
|
+
pid?: string | null;
|
|
74
|
+
from?: string;
|
|
75
|
+
to?: string[];
|
|
76
|
+
add_to?: AddToBatch[];
|
|
77
|
+
time?: number | null;
|
|
78
|
+
topic?: string;
|
|
79
|
+
type?: string;
|
|
80
|
+
size?: number;
|
|
81
|
+
message_sha256?: string;
|
|
82
|
+
body?: ThreadBody;
|
|
83
|
+
attachments?: ThreadAttachment[];
|
|
84
|
+
};
|
|
85
|
+
export type Thread = {
|
|
86
|
+
root_id: string;
|
|
87
|
+
trigger_id: string;
|
|
88
|
+
complete: boolean;
|
|
89
|
+
messages: ThreadMessage[];
|
|
90
|
+
};
|
|
91
|
+
export type AccessToken = {
|
|
92
|
+
accessToken: string;
|
|
93
|
+
/** fmsg address from the JWT `sub` claim. */
|
|
94
|
+
address: string;
|
|
95
|
+
expiresAtMs: number;
|
|
96
|
+
};
|
|
97
|
+
export type OutboundAttachment = {
|
|
98
|
+
filename: string;
|
|
99
|
+
data: Uint8Array;
|
|
100
|
+
contentType?: string;
|
|
101
|
+
};
|
|
102
|
+
export type SendInput = {
|
|
103
|
+
to: string[];
|
|
104
|
+
body: string;
|
|
105
|
+
type?: string;
|
|
106
|
+
topic?: string;
|
|
107
|
+
pid?: string;
|
|
108
|
+
important?: boolean;
|
|
109
|
+
noReply?: boolean;
|
|
110
|
+
attachments?: OutboundAttachment[];
|
|
111
|
+
signal?: AbortSignal;
|
|
112
|
+
};
|
|
113
|
+
export type SendResult = {
|
|
114
|
+
id: string;
|
|
115
|
+
time: number | null;
|
|
116
|
+
attachments: Attachment[];
|
|
117
|
+
};
|
|
118
|
+
export type ReactResult = {
|
|
119
|
+
id: string | null;
|
|
120
|
+
time: number | null;
|
|
121
|
+
};
|
|
122
|
+
export type WsEventType = "new_msg" | "delivered" | "recipients_added" | "reaction";
|
|
123
|
+
export type WsEvent = {
|
|
124
|
+
type: WsEventType | string;
|
|
125
|
+
data?: FmsgMessage;
|
|
126
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import WebSocket from "ws";
|
|
2
|
+
import type { FmsgClient } from "./client.js";
|
|
3
|
+
import type { WsEvent } from "./types.js";
|
|
4
|
+
/** Open the event WebSocket, authenticating with the bearer JWT in the header. */
|
|
5
|
+
export declare function openFmsgWebSocket(client: FmsgClient): Promise<WebSocket>;
|
|
6
|
+
export declare function parseWsEvent(raw: WebSocket.RawData): WsEvent | undefined;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import WebSocket from "ws";
|
|
2
|
+
import { normalizeMessageId, parseFmsgJson } from "./message-id.js";
|
|
3
|
+
/** Open the event WebSocket, authenticating with the bearer JWT in the header. */
|
|
4
|
+
export async function openFmsgWebSocket(client) {
|
|
5
|
+
const token = await client.getToken();
|
|
6
|
+
const url = new URL(client.apiUrl);
|
|
7
|
+
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
|
|
8
|
+
url.pathname = `${url.pathname.replace(/\/+$/u, "")}/fmsg/ws`;
|
|
9
|
+
url.search = "";
|
|
10
|
+
return new WebSocket(url, { headers: { authorization: `Bearer ${token.accessToken}` } });
|
|
11
|
+
}
|
|
12
|
+
export function parseWsEvent(raw) {
|
|
13
|
+
try {
|
|
14
|
+
const parsed = parseFmsgJson(raw.toString());
|
|
15
|
+
if (!parsed || typeof parsed.type !== "string")
|
|
16
|
+
return undefined;
|
|
17
|
+
const data = parsed.data && typeof parsed.data === "object" && parsed.data.id !== undefined
|
|
18
|
+
? { ...parsed.data, id: normalizeMessageId(parsed.data.id), reaction: parsed.data.reaction ?? null, reactions: parsed.data.reactions ?? [] }
|
|
19
|
+
: undefined;
|
|
20
|
+
return data ? { type: parsed.type, data } : { type: parsed.type };
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export type Transport = "stdio" | "http";
|
|
2
|
+
export type HttpConfig = {
|
|
3
|
+
host: string;
|
|
4
|
+
port: number;
|
|
5
|
+
/** Hostnames accepted in the Host header. Empty means: derive from the bind address (loopback only). */
|
|
6
|
+
allowedHosts: string[];
|
|
7
|
+
/** Origins (hostnames) accepted in the Origin header for browser callers; empty = same as allowedHosts. */
|
|
8
|
+
allowedOrigins: string[];
|
|
9
|
+
keyCacheMax: number;
|
|
10
|
+
keyCacheTtlMs: number;
|
|
11
|
+
};
|
|
12
|
+
export type Config = {
|
|
13
|
+
transport: Transport;
|
|
14
|
+
apiUrl: string;
|
|
15
|
+
/** Only set in stdio mode. */
|
|
16
|
+
apiKey?: string;
|
|
17
|
+
defaultDomain?: string;
|
|
18
|
+
directory?: Record<string, string>;
|
|
19
|
+
/** Hard cap on a single wait_for_message call. */
|
|
20
|
+
waitMaxSeconds: number;
|
|
21
|
+
/** Directory attachments may be saved under (stdio only); unset = anywhere. */
|
|
22
|
+
downloadDir?: string;
|
|
23
|
+
http: HttpConfig;
|
|
24
|
+
};
|
|
25
|
+
export declare const DEFAULT_HTTP_PORT = 8765;
|
|
26
|
+
export declare const DEFAULT_WAIT_MAX_SECONDS = 230;
|
|
27
|
+
export type ConfigOverrides = {
|
|
28
|
+
host?: string;
|
|
29
|
+
port?: number;
|
|
30
|
+
};
|
|
31
|
+
export declare function loadConfig(env: NodeJS.ProcessEnv, transport: Transport, overrides?: ConfigOverrides): Config;
|