@markmnl/fmsg-mcp 0.1.4 → 0.2.1
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/CHANGELOG.md +58 -0
- package/README.md +128 -26
- package/ROADMAP.md +52 -0
- package/SECURITY.md +78 -0
- package/dist/auth.d.ts +14 -6
- package/dist/auth.js +115 -49
- package/dist/client/client.d.ts +34 -24
- package/dist/client/client.js +152 -105
- package/dist/client/errors.d.ts +19 -0
- package/dist/client/errors.js +51 -0
- package/dist/client/index.d.ts +1 -0
- package/dist/client/stream.d.ts +12 -0
- package/dist/client/stream.js +83 -0
- package/dist/client/token-provider.d.ts +27 -0
- package/dist/client/token-provider.js +54 -0
- package/dist/client/types.d.ts +8 -4
- package/dist/client/url.d.ts +4 -0
- package/dist/client/url.js +35 -0
- package/dist/client/ws.d.ts +2 -1
- package/dist/client/ws.js +9 -3
- package/dist/config.d.ts +9 -3
- package/dist/config.js +21 -6
- package/dist/context.d.ts +6 -3
- package/dist/context.js +9 -2
- package/dist/download.d.ts +11 -0
- package/dist/download.js +34 -0
- package/dist/errors.d.ts +1 -1
- package/dist/errors.js +18 -26
- package/dist/http.d.ts +4 -3
- package/dist/http.js +246 -28
- package/dist/index.js +27 -11
- package/dist/instructions.js +12 -5
- package/dist/oauth/config.d.ts +11 -0
- package/dist/oauth/config.js +46 -0
- package/dist/oauth/errors.d.ts +18 -0
- package/dist/oauth/errors.js +40 -0
- package/dist/oauth/issuer.d.ts +31 -0
- package/dist/oauth/issuer.js +185 -0
- package/dist/oauth/provider.d.ts +23 -0
- package/dist/oauth/provider.js +95 -0
- package/dist/oauth/scopes.d.ts +6 -0
- package/dist/oauth/scopes.js +31 -0
- package/dist/render.d.ts +6 -1
- package/dist/render.js +27 -11
- package/dist/resources.js +18 -9
- package/dist/server.js +4 -0
- package/dist/thread.js +15 -14
- package/dist/tools/common.js +8 -3
- package/dist/tools/download.d.ts +2 -0
- package/dist/tools/download.js +38 -0
- package/dist/tools/identity.js +1 -1
- package/dist/tools/list.js +3 -3
- package/dist/tools/read.js +26 -43
- package/dist/tools/save.d.ts +2 -0
- package/dist/tools/save.js +84 -0
- package/dist/tools/send.js +9 -13
- package/dist/tools/wait.js +8 -6
- package/dist/wait.d.ts +1 -1
- package/dist/wait.js +161 -67
- package/docs/http-deployment.md +81 -0
- package/docs/oauth.md +169 -0
- package/docs/token-providers.md +94 -0
- package/package.json +8 -1
- package/server.json +54 -4
package/dist/auth.js
CHANGED
|
@@ -1,84 +1,150 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
2
|
import { OAuthError, OAuthErrorCode } from "@modelcontextprotocol/server";
|
|
3
3
|
import { FmsgClient, FmsgHttpError } from "./client/client.js";
|
|
4
|
+
import { safeErrorMessage } from "./client/redact.js";
|
|
4
5
|
export const FMSG_SCOPE = "fmsg";
|
|
5
|
-
/**
|
|
6
|
-
* HTTP mode: each request carries an fmsg API key as its bearer token. The key
|
|
7
|
-
* is exchanged once at the fmsg host, and the resulting client (which renews
|
|
8
|
-
* its own JWT) is cached by the key's hash. Raw keys are never stored or logged.
|
|
9
|
-
*/
|
|
6
|
+
/** Per-key clients retain credentials for renewal; upstream decides access. */
|
|
10
7
|
export class ApiKeyCallerProvider {
|
|
11
8
|
config;
|
|
12
9
|
log;
|
|
13
10
|
entries = new Map();
|
|
11
|
+
pending = new Map();
|
|
12
|
+
pendingClients = new Set();
|
|
13
|
+
// An opaque request lease survives SDK cloning and pins the caller across eviction.
|
|
14
|
+
leases = new Map();
|
|
15
|
+
timer;
|
|
16
|
+
closed = false;
|
|
14
17
|
constructor(config, log = () => undefined) {
|
|
15
18
|
this.config = config;
|
|
16
19
|
this.log = log;
|
|
20
|
+
this.timer = setInterval(() => this.evict(), Math.min(config.http.keyCacheTtlMs, 30_000)).unref();
|
|
17
21
|
}
|
|
18
|
-
static cacheKey(apiKey) {
|
|
19
|
-
|
|
22
|
+
static cacheKey(apiKey) { return createHash("sha256").update(apiKey).digest("hex"); }
|
|
23
|
+
releaseEntry(entry) {
|
|
24
|
+
if (!entry.active && this.entries.get(entry.key) !== entry)
|
|
25
|
+
entry.caller.client.close();
|
|
26
|
+
}
|
|
27
|
+
drop(entry) {
|
|
28
|
+
if (this.entries.get(entry.key) === entry)
|
|
29
|
+
this.entries.delete(entry.key);
|
|
30
|
+
this.releaseEntry(entry);
|
|
20
31
|
}
|
|
21
32
|
evict() {
|
|
22
33
|
const now = Date.now();
|
|
23
|
-
for (const
|
|
24
|
-
if (now - entry.lastUsed
|
|
25
|
-
this.
|
|
34
|
+
for (const entry of this.entries.values()) {
|
|
35
|
+
if (now - entry.lastUsed >= this.config.http.keyCacheTtlMs)
|
|
36
|
+
this.drop(entry);
|
|
26
37
|
}
|
|
27
38
|
while (this.entries.size > this.config.http.keyCacheMax) {
|
|
28
|
-
const oldest = [...this.entries.
|
|
39
|
+
const oldest = [...this.entries.values()].sort((a, b) => a.lastUsed - b.lastUsed)[0];
|
|
29
40
|
if (!oldest)
|
|
30
41
|
break;
|
|
31
|
-
this.
|
|
42
|
+
this.drop(oldest);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
async createEntry(token, key) {
|
|
46
|
+
const client = new FmsgClient(this.config.apiUrl, token, { allowInsecureHttp: this.config.allowInsecureHttp });
|
|
47
|
+
this.pendingClients.add(client);
|
|
48
|
+
try {
|
|
49
|
+
const address = await client.address();
|
|
50
|
+
if (this.closed)
|
|
51
|
+
throw new Error("server is closing");
|
|
52
|
+
const entry = { key, caller: { client, address, tokenExpiresAt: async () => (await client.getToken()).expiresAtMs }, lastUsed: Date.now(), active: 0 };
|
|
53
|
+
this.entries.set(key, entry);
|
|
54
|
+
this.log(safeErrorMessage(`authenticated ${address} (key ${key.slice(0, 8)}…)`));
|
|
55
|
+
return entry;
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
client.close();
|
|
59
|
+
throw error;
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
this.pendingClients.delete(client);
|
|
32
63
|
}
|
|
33
64
|
}
|
|
34
|
-
/** Bearer verifier for the MCP gate: exchange the key, return the caller's identity. */
|
|
35
65
|
async verifyAccessToken(token) {
|
|
36
|
-
if (
|
|
66
|
+
if (this.closed)
|
|
67
|
+
throw new OAuthError(OAuthErrorCode.ServerError, "server is closing");
|
|
68
|
+
if (!token.startsWith("fmsgk_"))
|
|
37
69
|
throw new OAuthError(OAuthErrorCode.InvalidToken, "bearer token must be an fmsg API key (fmsgk_...)");
|
|
38
|
-
}
|
|
39
70
|
const key = ApiKeyCallerProvider.cacheKey(token);
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
71
|
+
this.evict();
|
|
72
|
+
let entry;
|
|
73
|
+
try {
|
|
74
|
+
entry = this.entries.get(key);
|
|
75
|
+
if (!entry) {
|
|
76
|
+
let pending = this.pending.get(key);
|
|
77
|
+
if (!pending) {
|
|
78
|
+
if (this.pending.size >= this.config.http.keyCacheMax)
|
|
79
|
+
throw new Error("too many concurrent token exchanges");
|
|
80
|
+
pending = this.createEntry(token, key);
|
|
81
|
+
this.pending.set(key, pending);
|
|
82
|
+
void pending.finally(() => this.pending.delete(key)).catch(() => undefined);
|
|
51
83
|
}
|
|
52
|
-
|
|
53
|
-
throw new OAuthError(OAuthErrorCode.ServerError, "fmsg host unavailable for token exchange");
|
|
84
|
+
entry = await pending;
|
|
54
85
|
}
|
|
55
|
-
entry
|
|
56
|
-
|
|
57
|
-
lastUsed: Date.now(),
|
|
58
|
-
};
|
|
59
|
-
this.entries.set(key, entry);
|
|
86
|
+
entry.active++;
|
|
87
|
+
entry.lastUsed = Date.now();
|
|
60
88
|
this.evict();
|
|
61
|
-
|
|
89
|
+
const expiresAtMs = await entry.caller.tokenExpiresAt();
|
|
90
|
+
if (this.closed)
|
|
91
|
+
throw new Error("server is closing");
|
|
92
|
+
const lease = randomUUID();
|
|
93
|
+
this.leases.set(lease, entry);
|
|
94
|
+
return {
|
|
95
|
+
token: key, clientId: entry.caller.address, scopes: [FMSG_SCOPE],
|
|
96
|
+
expiresAt: Math.floor(expiresAtMs / 1000), extra: { cacheKey: key, callerLease: lease },
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
catch (error) {
|
|
100
|
+
if (entry) {
|
|
101
|
+
entry.active--;
|
|
102
|
+
this.drop(entry);
|
|
103
|
+
}
|
|
104
|
+
this.log(safeErrorMessage(`token exchange failed for ${key.slice(0, 8)}…: ${error instanceof Error ? error.message : String(error)}`));
|
|
105
|
+
if (error instanceof FmsgHttpError && [400, 401, 403].includes(error.status)) {
|
|
106
|
+
throw new OAuthError(OAuthErrorCode.InvalidToken, `fmsg host rejected the API key: ${safeErrorMessage(error)}`);
|
|
107
|
+
}
|
|
108
|
+
throw new OAuthError(OAuthErrorCode.ServerError, "fmsg host unavailable for token exchange");
|
|
62
109
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
scopes:
|
|
69
|
-
expiresAt: Math.floor(expiresAtMs / 1000),
|
|
70
|
-
extra: { cacheKey: key },
|
|
71
|
-
};
|
|
110
|
+
}
|
|
111
|
+
entryFor(auth) {
|
|
112
|
+
const lease = auth?.extra?.callerLease;
|
|
113
|
+
const entry = typeof lease === "string" ? this.leases.get(lease) : undefined;
|
|
114
|
+
return entry && auth?.token === entry.key && auth.extra?.cacheKey === entry.key &&
|
|
115
|
+
auth.clientId === entry.caller.address && auth.scopes.includes(FMSG_SCOPE) ? entry : undefined;
|
|
72
116
|
}
|
|
73
117
|
async forRequest(authInfo) {
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
if (!entry)
|
|
118
|
+
const entry = this.entryFor(authInfo);
|
|
119
|
+
if (this.closed || !entry)
|
|
77
120
|
throw new Error("not authenticated: send your fmsg API key as `Authorization: Bearer fmsgk_...`");
|
|
78
121
|
entry.lastUsed = Date.now();
|
|
79
122
|
return entry.caller;
|
|
80
123
|
}
|
|
81
|
-
|
|
82
|
-
|
|
124
|
+
/** The HTTP adapter releases this lease when the response or connection ends. */
|
|
125
|
+
release(authInfo) {
|
|
126
|
+
const entry = this.entryFor(authInfo);
|
|
127
|
+
if (!entry)
|
|
128
|
+
return;
|
|
129
|
+
this.leases.delete(authInfo.extra.callerLease);
|
|
130
|
+
entry.active--;
|
|
131
|
+
this.releaseEntry(entry);
|
|
132
|
+
}
|
|
133
|
+
invalidate(caller) {
|
|
134
|
+
for (const entry of this.entries.values())
|
|
135
|
+
if (entry.caller === caller)
|
|
136
|
+
this.drop(entry);
|
|
137
|
+
}
|
|
138
|
+
close() {
|
|
139
|
+
this.closed = true;
|
|
140
|
+
clearInterval(this.timer);
|
|
141
|
+
for (const entry of [...this.entries.values(), ...this.leases.values()])
|
|
142
|
+
entry.caller.client.close();
|
|
143
|
+
for (const client of this.pendingClients)
|
|
144
|
+
client.close();
|
|
145
|
+
this.entries.clear();
|
|
146
|
+
this.leases.clear();
|
|
147
|
+
this.pending.clear();
|
|
83
148
|
}
|
|
149
|
+
get size() { return this.entries.size; }
|
|
84
150
|
}
|
package/dist/client/client.d.ts
CHANGED
|
@@ -1,39 +1,40 @@
|
|
|
1
|
+
import { type TokenProvider } from "./token-provider.js";
|
|
1
2
|
import type { AccessToken, Attachment, FmsgMessage, ReactResult, SendInput, SendResult, Thread } from "./types.js";
|
|
3
|
+
export { FmsgHttpError } from "./errors.js";
|
|
2
4
|
export type FetchLike = typeof fetch;
|
|
3
5
|
export type FmsgClientOptions = {
|
|
4
6
|
fetch?: FetchLike;
|
|
5
|
-
/** Refresh
|
|
7
|
+
/** Refresh margin (default 5 minutes), capped at half the token's remaining lifetime on acquisition. */
|
|
6
8
|
refreshMarginMs?: number;
|
|
7
|
-
/** Per-request timeout (default 60 s). */
|
|
9
|
+
/** Per-request timeout; attachment streams use separate header/idle budgets (default 60 s). */
|
|
8
10
|
timeoutMs?: number;
|
|
11
|
+
/** Allow HTTP outside loopback only on an explicitly trusted network. */
|
|
12
|
+
allowInsecureHttp?: boolean;
|
|
13
|
+
/** Renew event sockets when their bearer expires (delegated OAuth clients). */
|
|
14
|
+
reconnectOnTokenExpiry?: boolean;
|
|
9
15
|
};
|
|
10
|
-
/** An HTTP error from the fmsg Web API, with the status and the host's own error text. */
|
|
11
|
-
export declare class FmsgHttpError extends Error {
|
|
12
|
-
readonly status: number;
|
|
13
|
-
readonly method: string;
|
|
14
|
-
readonly path: string;
|
|
15
|
-
/** Machine-readable `code` from the body, when the host sends one (thread routes). */
|
|
16
|
-
readonly code?: string | undefined;
|
|
17
|
-
constructor(message: string, status: number, method: string, path: string,
|
|
18
|
-
/** Machine-readable `code` from the body, when the host sends one (thread routes). */
|
|
19
|
-
code?: string | undefined);
|
|
20
|
-
}
|
|
21
16
|
/**
|
|
22
|
-
* Client for the fmsg Web API (FMSG-003).
|
|
23
|
-
*
|
|
17
|
+
* Client for the fmsg Web API (FMSG-003). Accepts an API key or a caller-bound
|
|
18
|
+
* token provider, renews tokens ahead of expiry, and retries once on 401.
|
|
24
19
|
*/
|
|
25
20
|
export declare class FmsgClient {
|
|
26
|
-
private readonly apiKey;
|
|
27
21
|
private readonly options;
|
|
28
22
|
readonly apiUrl: string;
|
|
29
23
|
private token?;
|
|
30
|
-
private
|
|
31
|
-
|
|
24
|
+
private renewal?;
|
|
25
|
+
private provider?;
|
|
26
|
+
private boundAddress?;
|
|
27
|
+
private refreshAtMs;
|
|
28
|
+
private readonly lifetime;
|
|
29
|
+
constructor(apiUrl: string, credentials: string | TokenProvider, options?: FmsgClientOptions);
|
|
32
30
|
private get fetchImpl();
|
|
33
|
-
|
|
34
|
-
address
|
|
35
|
-
|
|
36
|
-
|
|
31
|
+
get reconnectOnTokenExpiry(): boolean;
|
|
32
|
+
/** The provider's authenticated address, pinned for this client's lifetime. */
|
|
33
|
+
address(signal?: AbortSignal): Promise<string>;
|
|
34
|
+
getToken(force?: boolean, signal?: AbortSignal): Promise<AccessToken>;
|
|
35
|
+
private startRenewal;
|
|
36
|
+
/** Release credentials and cancel outstanding work when the client is no longer used. */
|
|
37
|
+
close(): void;
|
|
37
38
|
private request;
|
|
38
39
|
private json;
|
|
39
40
|
listInbox(limit?: number, offset?: number, signal?: AbortSignal): Promise<FmsgMessage[]>;
|
|
@@ -67,10 +68,15 @@ export declare class FmsgClient {
|
|
|
67
68
|
added: number;
|
|
68
69
|
}>;
|
|
69
70
|
react(id: string, emoji: string | null, signal?: AbortSignal): Promise<ReactResult>;
|
|
70
|
-
downloadAttachment(id: string, filename: string, signal?: AbortSignal): Promise<{
|
|
71
|
+
downloadAttachment(id: string, filename: string, signal?: AbortSignal, maxBytes?: number): Promise<{
|
|
71
72
|
data: Uint8Array;
|
|
72
73
|
contentType?: string;
|
|
73
74
|
}>;
|
|
75
|
+
/** Caller must consume or cancel the stream. Progress resets the idle budget; cancellation remains active. */
|
|
76
|
+
streamAttachment(id: string, filename: string, signal?: AbortSignal): Promise<{
|
|
77
|
+
stream: ReadableStream<Uint8Array>;
|
|
78
|
+
contentType?: string;
|
|
79
|
+
}>;
|
|
74
80
|
deleteMessage(id: string, signal?: AbortSignal): Promise<void>;
|
|
75
81
|
private createDraft;
|
|
76
82
|
uploadAttachment(draftId: string, attachment: {
|
|
@@ -78,6 +84,10 @@ export declare class FmsgClient {
|
|
|
78
84
|
data: Uint8Array;
|
|
79
85
|
contentType?: string;
|
|
80
86
|
}, signal?: AbortSignal): Promise<Attachment>;
|
|
81
|
-
/**
|
|
87
|
+
/**
|
|
88
|
+
* Draft → attach → send. Selected secret patterns in body/topic are replaced;
|
|
89
|
+
* the result reports their count and the sent topic. Attachments are unchanged.
|
|
90
|
+
* The draft is deleted if any step after creation fails.
|
|
91
|
+
*/
|
|
82
92
|
send(input: SendInput): Promise<SendResult>;
|
|
83
93
|
}
|
package/dist/client/client.js
CHANGED
|
@@ -1,130 +1,152 @@
|
|
|
1
1
|
import { normalizeFmsgAddress } from "../address.js";
|
|
2
2
|
import { normalizeMessageId, parseFmsgJson, stringifyWithIds } from "./message-id.js";
|
|
3
3
|
import { redactSecrets } from "./redact.js";
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
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
|
-
}
|
|
4
|
+
import { normalizeApiUrl } from "./url.js";
|
|
5
|
+
import { readBytes, withIdleTimeout } from "./stream.js";
|
|
6
|
+
import { FmsgHttpError, readError } from "./errors.js";
|
|
7
|
+
import { ApiKeyTokenProvider } from "./token-provider.js";
|
|
8
|
+
export { FmsgHttpError } from "./errors.js";
|
|
45
9
|
function withId(message, id) {
|
|
46
10
|
return { ...message, id, terminal: message.terminal === true, reaction: message.reaction ?? null, reactions: message.reactions ?? [] };
|
|
47
11
|
}
|
|
12
|
+
/** Detach one waiter without cancelling work another caller still needs. */
|
|
13
|
+
function withAbort(promise, signal) {
|
|
14
|
+
if (!signal)
|
|
15
|
+
return promise;
|
|
16
|
+
return new Promise((resolve, reject) => {
|
|
17
|
+
const onAbort = () => { signal.removeEventListener("abort", onAbort); reject(signal.reason); };
|
|
18
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
19
|
+
if (signal.aborted)
|
|
20
|
+
onAbort();
|
|
21
|
+
// Always observe the operation, including a provider that ignores cancellation.
|
|
22
|
+
promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", onAbort));
|
|
23
|
+
});
|
|
24
|
+
}
|
|
48
25
|
/**
|
|
49
|
-
* Client for the fmsg Web API (FMSG-003).
|
|
50
|
-
*
|
|
26
|
+
* Client for the fmsg Web API (FMSG-003). Accepts an API key or a caller-bound
|
|
27
|
+
* token provider, renews tokens ahead of expiry, and retries once on 401.
|
|
51
28
|
*/
|
|
52
29
|
export class FmsgClient {
|
|
53
|
-
apiKey;
|
|
54
30
|
options;
|
|
55
31
|
apiUrl;
|
|
56
32
|
token;
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
33
|
+
renewal;
|
|
34
|
+
provider;
|
|
35
|
+
boundAddress;
|
|
36
|
+
refreshAtMs = 0;
|
|
37
|
+
lifetime = new AbortController();
|
|
38
|
+
constructor(apiUrl, credentials, options = {}) {
|
|
60
39
|
this.options = options;
|
|
61
|
-
this.apiUrl = apiUrl
|
|
62
|
-
if (
|
|
63
|
-
throw new Error("
|
|
64
|
-
|
|
65
|
-
|
|
40
|
+
this.apiUrl = normalizeApiUrl(apiUrl, options.allowInsecureHttp);
|
|
41
|
+
if (options.refreshMarginMs !== undefined && (!Number.isFinite(options.refreshMarginMs) || options.refreshMarginMs < 0)) {
|
|
42
|
+
throw new Error("refreshMarginMs must be a finite non-negative number");
|
|
43
|
+
}
|
|
44
|
+
this.provider = typeof credentials === "string" ? new ApiKeyTokenProvider(credentials, this.fetchImpl) : credentials;
|
|
45
|
+
if (!this.provider || typeof this.provider.getToken !== "function")
|
|
46
|
+
throw new Error("a token provider must implement getToken");
|
|
66
47
|
}
|
|
67
48
|
get fetchImpl() {
|
|
68
49
|
return this.options.fetch ?? fetch;
|
|
69
50
|
}
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
51
|
+
get reconnectOnTokenExpiry() { return this.options.reconnectOnTokenExpiry === true; }
|
|
52
|
+
/** The provider's authenticated address, pinned for this client's lifetime. */
|
|
53
|
+
async address(signal) {
|
|
54
|
+
return (await this.getToken(false, signal)).address;
|
|
73
55
|
}
|
|
74
|
-
async getToken(force = false) {
|
|
75
|
-
|
|
76
|
-
|
|
56
|
+
async getToken(force = false, signal) {
|
|
57
|
+
signal?.throwIfAborted();
|
|
58
|
+
this.lifetime.signal.throwIfAborted();
|
|
59
|
+
if (!this.renewal && !force && this.token && this.refreshAtMs > Date.now())
|
|
77
60
|
return this.token;
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
this.tokenPromise = this.exchangeToken();
|
|
61
|
+
const renewal = this.renewal ?? this.startRenewal(force);
|
|
62
|
+
renewal.waiters++;
|
|
81
63
|
try {
|
|
82
|
-
|
|
83
|
-
return this.token;
|
|
64
|
+
return await withAbort(renewal.promise, signal);
|
|
84
65
|
}
|
|
85
66
|
finally {
|
|
86
|
-
|
|
67
|
+
renewal.waiters--;
|
|
68
|
+
if (!renewal.waiters && this.renewal === renewal) {
|
|
69
|
+
this.renewal = undefined;
|
|
70
|
+
renewal.abort.abort();
|
|
71
|
+
}
|
|
87
72
|
}
|
|
88
73
|
}
|
|
89
|
-
|
|
90
|
-
const
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
74
|
+
startRenewal(forceRefresh) {
|
|
75
|
+
const provider = this.provider;
|
|
76
|
+
const renewal = { promise: undefined, abort: new AbortController(), waiters: 0 };
|
|
77
|
+
this.renewal = renewal;
|
|
78
|
+
this.token = undefined;
|
|
79
|
+
const signal = AbortSignal.any([this.lifetime.signal, renewal.abort.signal]);
|
|
80
|
+
const timer = setTimeout(() => renewal.abort.abort(new DOMException("token renewal timed out", "TimeoutError")), this.options.timeoutMs ?? 60_000).unref();
|
|
81
|
+
const acquiring = Promise.resolve().then(() => {
|
|
82
|
+
signal.throwIfAborted();
|
|
83
|
+
return provider.getToken({ apiUrl: this.apiUrl, signal, forceRefresh });
|
|
94
84
|
});
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
85
|
+
renewal.promise = withAbort(acquiring, signal).then(value => {
|
|
86
|
+
signal.throwIfAborted();
|
|
87
|
+
const address = typeof value?.address === "string" ? normalizeFmsgAddress(value.address) : undefined;
|
|
88
|
+
if (!address)
|
|
89
|
+
throw new Error("token provider returned an invalid fmsg address");
|
|
90
|
+
if (typeof value.accessToken !== "string" || !/^[A-Za-z0-9._~+\/-]+=*$/u.test(value.accessToken) || value.accessToken.startsWith("fmsgk_")) {
|
|
91
|
+
throw new Error("token provider must return a Web API bearer access token");
|
|
92
|
+
}
|
|
93
|
+
const now = Date.now();
|
|
94
|
+
if (!Number.isFinite(value.expiresAtMs) || value.expiresAtMs <= now)
|
|
95
|
+
throw new Error("token provider returned an invalid or expired token lifetime");
|
|
96
|
+
if (this.boundAddress !== undefined && address !== this.boundAddress)
|
|
97
|
+
throw new Error("token provider changed the authenticated address; create a new client for a different identity");
|
|
98
|
+
this.boundAddress = address;
|
|
99
|
+
const token = Object.freeze({ accessToken: value.accessToken, address, expiresAtMs: value.expiresAtMs });
|
|
100
|
+
this.refreshAtMs = token.expiresAtMs - Math.min(this.options.refreshMarginMs ?? 300_000, (token.expiresAtMs - now) / 2);
|
|
101
|
+
this.token = token;
|
|
102
|
+
return token;
|
|
103
|
+
}).finally(() => {
|
|
104
|
+
clearTimeout(timer);
|
|
105
|
+
if (this.renewal === renewal)
|
|
106
|
+
this.renewal = undefined;
|
|
107
|
+
});
|
|
108
|
+
return renewal;
|
|
111
109
|
}
|
|
112
|
-
|
|
113
|
-
|
|
110
|
+
/** Release credentials and cancel outstanding work when the client is no longer used. */
|
|
111
|
+
close() {
|
|
112
|
+
if (this.lifetime.signal.aborted)
|
|
113
|
+
return;
|
|
114
|
+
this.lifetime.abort();
|
|
115
|
+
this.token = undefined;
|
|
116
|
+
const provider = this.provider;
|
|
117
|
+
this.provider = undefined;
|
|
118
|
+
provider?.close?.();
|
|
119
|
+
}
|
|
120
|
+
async request(path, init = {}, retry401 = true, streaming = false) {
|
|
121
|
+
init.signal?.throwIfAborted();
|
|
122
|
+
const token = await this.getToken(false, init.signal ?? undefined);
|
|
114
123
|
const headers = new Headers(init.headers);
|
|
115
124
|
headers.set("authorization", `Bearer ${token.accessToken}`);
|
|
116
|
-
const
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
125
|
+
const headerDeadline = new AbortController();
|
|
126
|
+
const headerTimer = streaming ? setTimeout(() => headerDeadline.abort(new DOMException("attachment response headers timed out", "TimeoutError")), this.options.timeoutMs ?? 60_000).unref() : undefined;
|
|
127
|
+
const timeout = streaming ? headerDeadline.signal : AbortSignal.timeout(this.options.timeoutMs ?? 60_000);
|
|
128
|
+
const signal = AbortSignal.any([this.lifetime.signal, timeout, ...(init.signal ? [init.signal] : [])]);
|
|
129
|
+
try {
|
|
130
|
+
signal.throwIfAborted();
|
|
131
|
+
const response = await this.fetchImpl(`${this.apiUrl}${path}`, { ...init, headers, signal, redirect: "error" });
|
|
132
|
+
if (response.status === 401 && retry401) {
|
|
133
|
+
await response.body?.cancel();
|
|
134
|
+
// A slower 401 may refer to a token another request already renewed.
|
|
135
|
+
if (!this.token || this.token === token || this.token.expiresAtMs <= Date.now()) {
|
|
136
|
+
await this.getToken(true, init.signal ?? undefined);
|
|
137
|
+
}
|
|
138
|
+
return this.request(path, init, false, streaming);
|
|
139
|
+
}
|
|
140
|
+
if (!response.ok) {
|
|
141
|
+
const { message, code } = await readError(response);
|
|
142
|
+
const method = init.method ?? "GET";
|
|
143
|
+
throw new FmsgHttpError(message, response.status, method, path, code, response.status === 403 && /\bBearer\b.*\berror="insufficient_scope"/iu.test(response.headers.get("www-authenticate") ?? ""));
|
|
144
|
+
}
|
|
145
|
+
return response;
|
|
121
146
|
}
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
const method = init.method ?? "GET";
|
|
125
|
-
throw new FmsgHttpError(redactSecrets(message).text, response.status, method, path, code);
|
|
147
|
+
finally {
|
|
148
|
+
clearTimeout(headerTimer);
|
|
126
149
|
}
|
|
127
|
-
return response;
|
|
128
150
|
}
|
|
129
151
|
async json(path, init) {
|
|
130
152
|
const response = await this.request(path, init);
|
|
@@ -195,8 +217,14 @@ export class FmsgClient {
|
|
|
195
217
|
}
|
|
196
218
|
/** Download by a `download` path returned from thread/messages (`/fmsg/...`). */
|
|
197
219
|
async downloadPath(path, signal) {
|
|
198
|
-
|
|
199
|
-
|
|
220
|
+
// Paths come from upstream message data. Reject normalization tricks and
|
|
221
|
+
// routes outside the documented body/attachment download endpoints.
|
|
222
|
+
if (!/^\/fmsg\/[0-9]+\/(?:data|attach\/[^/?#\\]+)$/u.test(path) || /[\u0000-\u0020\\]/u.test(path)) {
|
|
223
|
+
throw new Error("refusing an invalid fmsg download path");
|
|
224
|
+
}
|
|
225
|
+
const normalized = new URL(path, "https://example.com");
|
|
226
|
+
if (normalized.pathname !== path || normalized.search || normalized.hash)
|
|
227
|
+
throw new Error("refusing an invalid fmsg download path");
|
|
200
228
|
const response = await this.request(path, { signal });
|
|
201
229
|
const contentType = response.headers.get("content-type") ?? undefined;
|
|
202
230
|
return { data: new Uint8Array(await response.arrayBuffer()), ...(contentType ? { contentType } : {}) };
|
|
@@ -232,11 +260,22 @@ export class FmsgClient {
|
|
|
232
260
|
time: result.time ?? null,
|
|
233
261
|
};
|
|
234
262
|
}
|
|
235
|
-
async downloadAttachment(id, filename, signal) {
|
|
263
|
+
async downloadAttachment(id, filename, signal, maxBytes) {
|
|
264
|
+
const { stream, contentType } = await this.streamAttachment(id, filename, signal);
|
|
265
|
+
const { data } = await readBytes(stream, maxBytes);
|
|
266
|
+
return { data, ...(contentType ? { contentType } : {}) };
|
|
267
|
+
}
|
|
268
|
+
/** Caller must consume or cancel the stream. Progress resets the idle budget; cancellation remains active. */
|
|
269
|
+
async streamAttachment(id, filename, signal) {
|
|
236
270
|
const mid = normalizeMessageId(id);
|
|
237
|
-
|
|
271
|
+
if (!filename || filename === "." || filename === ".." || /[/\\\u0000]/u.test(filename)) {
|
|
272
|
+
throw new Error("use an attachment filename without directory components");
|
|
273
|
+
}
|
|
274
|
+
const response = await this.request(`/fmsg/${encodeURIComponent(mid)}/attach/${encodeURIComponent(filename)}`, { signal }, true, true);
|
|
238
275
|
const contentType = response.headers.get("content-type") ?? undefined;
|
|
239
|
-
|
|
276
|
+
if (!response.body)
|
|
277
|
+
throw new Error("attachment response has no body");
|
|
278
|
+
return { stream: withIdleTimeout(response.body, this.options.timeoutMs ?? 60_000), ...(contentType ? { contentType } : {}) };
|
|
240
279
|
}
|
|
241
280
|
async deleteMessage(id, signal) {
|
|
242
281
|
const mid = normalizeMessageId(id);
|
|
@@ -278,14 +317,20 @@ export class FmsgClient {
|
|
|
278
317
|
const result = parseFmsgJson(await response.text());
|
|
279
318
|
return { filename: result.filename ?? attachment.filename, size: result.size ?? attachment.data.byteLength };
|
|
280
319
|
}
|
|
281
|
-
/**
|
|
320
|
+
/**
|
|
321
|
+
* Draft → attach → send. Selected secret patterns in body/topic are replaced;
|
|
322
|
+
* the result reports their count and the sent topic. Attachments are unchanged.
|
|
323
|
+
* The draft is deleted if any step after creation fails.
|
|
324
|
+
*/
|
|
282
325
|
async send(input) {
|
|
283
326
|
if (input.to.length === 0)
|
|
284
327
|
throw new Error("at least one recipient is required");
|
|
285
328
|
if (input.pid && input.topic)
|
|
286
329
|
throw new Error("a reply (pid) cannot carry a topic");
|
|
287
|
-
const from = await this.address();
|
|
288
|
-
const
|
|
330
|
+
const from = await this.address(input.signal);
|
|
331
|
+
const body = redactSecrets(input.body);
|
|
332
|
+
const topic = redactSecrets(input.pid ? "" : (input.topic ?? ""));
|
|
333
|
+
const draftId = await this.createDraft({ ...input, body: body.text, topic: topic.text }, from);
|
|
289
334
|
try {
|
|
290
335
|
const attachments = [];
|
|
291
336
|
for (const attachment of input.attachments ?? []) {
|
|
@@ -300,6 +345,8 @@ export class FmsgClient {
|
|
|
300
345
|
id: result.id === undefined || result.id === null ? draftId : normalizeMessageId(result.id),
|
|
301
346
|
time: result.time ?? null,
|
|
302
347
|
attachments,
|
|
348
|
+
redactions: body.count + topic.count,
|
|
349
|
+
topic: topic.text,
|
|
303
350
|
};
|
|
304
351
|
}
|
|
305
352
|
catch (error) {
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** An HTTP error from the fmsg Web API, with the status and the host's own error text. */
|
|
2
|
+
export declare class FmsgHttpError extends Error {
|
|
3
|
+
readonly status: number;
|
|
4
|
+
readonly method: string;
|
|
5
|
+
readonly path: string;
|
|
6
|
+
/** Machine-readable `code` from the body, when the host sends one (thread routes). */
|
|
7
|
+
readonly code?: string | undefined;
|
|
8
|
+
/** The Web API rejected a delegated scope or closed route. */
|
|
9
|
+
readonly insufficientScope: boolean;
|
|
10
|
+
constructor(message: string, status: number, method: string, path: string,
|
|
11
|
+
/** Machine-readable `code` from the body, when the host sends one (thread routes). */
|
|
12
|
+
code?: string | undefined,
|
|
13
|
+
/** The Web API rejected a delegated scope or closed route. */
|
|
14
|
+
insufficientScope?: boolean);
|
|
15
|
+
}
|
|
16
|
+
export declare function readError(response: Response): Promise<{
|
|
17
|
+
message: string;
|
|
18
|
+
code?: string;
|
|
19
|
+
}>;
|