@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.
Files changed (64) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/README.md +128 -26
  3. package/ROADMAP.md +52 -0
  4. package/SECURITY.md +78 -0
  5. package/dist/auth.d.ts +14 -6
  6. package/dist/auth.js +115 -49
  7. package/dist/client/client.d.ts +34 -24
  8. package/dist/client/client.js +152 -105
  9. package/dist/client/errors.d.ts +19 -0
  10. package/dist/client/errors.js +51 -0
  11. package/dist/client/index.d.ts +1 -0
  12. package/dist/client/stream.d.ts +12 -0
  13. package/dist/client/stream.js +83 -0
  14. package/dist/client/token-provider.d.ts +27 -0
  15. package/dist/client/token-provider.js +54 -0
  16. package/dist/client/types.d.ts +8 -4
  17. package/dist/client/url.d.ts +4 -0
  18. package/dist/client/url.js +35 -0
  19. package/dist/client/ws.d.ts +2 -1
  20. package/dist/client/ws.js +9 -3
  21. package/dist/config.d.ts +9 -3
  22. package/dist/config.js +21 -6
  23. package/dist/context.d.ts +6 -3
  24. package/dist/context.js +9 -2
  25. package/dist/download.d.ts +11 -0
  26. package/dist/download.js +34 -0
  27. package/dist/errors.d.ts +1 -1
  28. package/dist/errors.js +18 -26
  29. package/dist/http.d.ts +4 -3
  30. package/dist/http.js +246 -28
  31. package/dist/index.js +27 -11
  32. package/dist/instructions.js +12 -5
  33. package/dist/oauth/config.d.ts +11 -0
  34. package/dist/oauth/config.js +46 -0
  35. package/dist/oauth/errors.d.ts +18 -0
  36. package/dist/oauth/errors.js +40 -0
  37. package/dist/oauth/issuer.d.ts +31 -0
  38. package/dist/oauth/issuer.js +185 -0
  39. package/dist/oauth/provider.d.ts +23 -0
  40. package/dist/oauth/provider.js +95 -0
  41. package/dist/oauth/scopes.d.ts +6 -0
  42. package/dist/oauth/scopes.js +31 -0
  43. package/dist/render.d.ts +6 -1
  44. package/dist/render.js +27 -11
  45. package/dist/resources.js +18 -9
  46. package/dist/server.js +4 -0
  47. package/dist/thread.js +15 -14
  48. package/dist/tools/common.js +8 -3
  49. package/dist/tools/download.d.ts +2 -0
  50. package/dist/tools/download.js +38 -0
  51. package/dist/tools/identity.js +1 -1
  52. package/dist/tools/list.js +3 -3
  53. package/dist/tools/read.js +26 -43
  54. package/dist/tools/save.d.ts +2 -0
  55. package/dist/tools/save.js +84 -0
  56. package/dist/tools/send.js +9 -13
  57. package/dist/tools/wait.js +8 -6
  58. package/dist/wait.d.ts +1 -1
  59. package/dist/wait.js +161 -67
  60. package/docs/http-deployment.md +81 -0
  61. package/docs/oauth.md +169 -0
  62. package/docs/token-providers.md +94 -0
  63. package/package.json +8 -1
  64. 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
- return createHash("sha256").update(apiKey).digest("hex");
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 [key, entry] of this.entries) {
24
- if (now - entry.lastUsed > this.config.http.keyCacheTtlMs)
25
- this.entries.delete(key);
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.entries()].sort((a, b) => a[1].lastUsed - b[1].lastUsed)[0];
39
+ const oldest = [...this.entries.values()].sort((a, b) => a.lastUsed - b.lastUsed)[0];
29
40
  if (!oldest)
30
41
  break;
31
- this.entries.delete(oldest[0]);
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 (!token.startsWith("fmsgk_")) {
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
- let entry = this.entries.get(key);
41
- if (!entry) {
42
- const client = new FmsgClient(this.config.apiUrl, token);
43
- let address;
44
- try {
45
- address = await client.address();
46
- }
47
- catch (error) {
48
- if (error instanceof FmsgHttpError && (error.status === 401 || error.status === 403 || error.status === 400)) {
49
- this.log(`rejected api key ${key.slice(0, 8)}…: ${error.status} ${error.message}`);
50
- throw new OAuthError(OAuthErrorCode.InvalidToken, `fmsg host rejected the API key: ${error.message}`);
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
- this.log(`token exchange failed for ${key.slice(0, 8)}…: ${error instanceof Error ? error.message : String(error)}`);
53
- throw new OAuthError(OAuthErrorCode.ServerError, "fmsg host unavailable for token exchange");
84
+ entry = await pending;
54
85
  }
55
- entry = {
56
- caller: { client, address, tokenExpiresAt: async () => (await client.getToken()).expiresAtMs },
57
- lastUsed: Date.now(),
58
- };
59
- this.entries.set(key, entry);
86
+ entry.active++;
87
+ entry.lastUsed = Date.now();
60
88
  this.evict();
61
- this.log(`authenticated ${address} (key ${key.slice(0, 8)}…)`);
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
- entry.lastUsed = Date.now();
64
- const expiresAtMs = await entry.caller.tokenExpiresAt();
65
- return {
66
- token: key,
67
- clientId: entry.caller.address,
68
- scopes: [FMSG_SCOPE],
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 key = typeof authInfo?.extra?.cacheKey === "string" ? authInfo.extra.cacheKey : authInfo?.token;
75
- const entry = key ? this.entries.get(key) : undefined;
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
- get size() {
82
- return this.entries.size;
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
  }
@@ -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 the access token this long before it expires (default 5 minutes). */
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). Exchanges an `fmsgk_` API key for a
23
- * short-lived JWT, refreshes it ahead of expiry, and retries once on 401.
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 tokenPromise?;
31
- constructor(apiUrl: string, apiKey: string, options?: FmsgClientOptions);
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
- /** The address this client acts as (from the JWT `sub`), exchanging the key if needed. */
34
- address(): Promise<string>;
35
- getToken(force?: boolean): Promise<AccessToken>;
36
- private exchangeToken;
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
- /** Draft → attach → send. The draft is deleted if any step after creation fails. */
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
  }
@@ -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
- /** 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
- }
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). Exchanges an `fmsgk_` API key for a
50
- * short-lived JWT, refreshes it ahead of expiry, and retries once on 401.
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
- tokenPromise;
58
- constructor(apiUrl, apiKey, options = {}) {
59
- this.apiKey = apiKey;
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.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_");
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
- /** The address this client acts as (from the JWT `sub`), exchanging the key if needed. */
71
- async address() {
72
- return (await this.getToken()).address;
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
- const margin = this.options.refreshMarginMs ?? 300_000;
76
- if (!force && this.token && this.token.expiresAtMs - margin > Date.now())
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
- if (!force && this.tokenPromise)
79
- return this.tokenPromise;
80
- this.tokenPromise = this.exchangeToken();
61
+ const renewal = this.renewal ?? this.startRenewal(force);
62
+ renewal.waiters++;
81
63
  try {
82
- this.token = await this.tokenPromise;
83
- return this.token;
64
+ return await withAbort(renewal.promise, signal);
84
65
  }
85
66
  finally {
86
- this.tokenPromise = undefined;
67
+ renewal.waiters--;
68
+ if (!renewal.waiters && this.renewal === renewal) {
69
+ this.renewal = undefined;
70
+ renewal.abort.abort();
71
+ }
87
72
  }
88
73
  }
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),
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
- 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 };
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
- async request(path, init = {}, retry401 = true) {
113
- const token = await this.getToken();
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 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);
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
- 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);
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
- if (!path.startsWith("/fmsg/") || path.includes("://"))
199
- throw new Error(`refusing to download non-fmsg path ${path}`);
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
- const response = await this.request(`/fmsg/${encodeURIComponent(mid)}/attach/${encodeURIComponent(filename)}`, { signal });
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
- return { data: new Uint8Array(await response.arrayBuffer()), ...(contentType ? { contentType } : {}) };
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
- /** Draft → attach → send. The draft is deleted if any step after creation fails. */
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 draftId = await this.createDraft(input, from);
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
+ }>;