@softeria/ms-365-mcp-server 0.150.2 → 0.151.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.
@@ -4,6 +4,12 @@ import logger from "./logger.js";
4
4
  import { auditLog, getUserIdentityForAudit } from "./audit-log.js";
5
5
  import { isDestructiveOperation } from "./lib/destructive-ops.js";
6
6
  import { describePathParam } from "./lib/path-params.js";
7
+ import { getAttachmentMinting } from "./lib/attachment-minting.js";
8
+ import {
9
+ buildAttachmentUrl,
10
+ isPlainGraphPath,
11
+ TicketStoreFullError
12
+ } from "./lib/attachment-tickets.js";
7
13
  import {
8
14
  anyFieldPresent,
9
15
  isTransportEnvelope,
@@ -80,6 +86,142 @@ function clampTopQueryParam(queryParams) {
80
86
  logger.info(`Clamping $top from ${requested} to ${cap} (MS365_MCP_MAX_TOP)`);
81
87
  queryParams["$top"] = String(cap);
82
88
  }
89
+ const OUTLOOK_MAIL_PATH = /^\/(?:me|users\/[^/]+)(?:\/(?:mailFolders|childFolders)\/[^/]+)*\/messages(?:\/delta\(\))?$/i;
90
+ function isOutlookMailPath(path2) {
91
+ return OUTLOOK_MAIL_PATH.test(path2);
92
+ }
93
+ function readQuotedSegment(expr, start) {
94
+ let j = start + 1;
95
+ let segment = "";
96
+ while (j < expr.length) {
97
+ if (expr[j] === "\\" && expr[j + 1] === "\\") {
98
+ segment += "\\\\";
99
+ j += 2;
100
+ continue;
101
+ }
102
+ if (expr[j] === "\\" && expr[j + 1] === '"') {
103
+ segment += '\\"';
104
+ j += 2;
105
+ continue;
106
+ }
107
+ if (expr[j] === '"') return { segment, end: j };
108
+ segment += expr[j];
109
+ j += 1;
110
+ }
111
+ return void 0;
112
+ }
113
+ const MAIL_SEARCH_PROPERTIES = /* @__PURE__ */ new Set([
114
+ "attachment",
115
+ "bcc",
116
+ "body",
117
+ "category",
118
+ "cc",
119
+ "from",
120
+ "hasattachment",
121
+ "hasattachments",
122
+ "importance",
123
+ "kind",
124
+ "participants",
125
+ "received",
126
+ "recipients",
127
+ "sent",
128
+ "size",
129
+ "subject",
130
+ "to"
131
+ ]);
132
+ const CLAUSE_HEAD = /^([A-Za-z]+)(?::|<=|>=|<>|=|<|>)\S/;
133
+ const BOOLEAN_JOIN = /\s(?:AND|OR|NOT)\s/;
134
+ function clauseHead(segment) {
135
+ const head = CLAUSE_HEAD.exec(segment);
136
+ return head && MAIL_SEARCH_PROPERTIES.has(head[1].toLowerCase()) ? head : null;
137
+ }
138
+ function balanceTrailingSlashes(text) {
139
+ const slashes = text.length - text.replace(/\\+$/, "").length;
140
+ return slashes % 2 === 1 ? `${text}\\` : text;
141
+ }
142
+ function classifyRun(segment, introducedByProperty) {
143
+ if (introducedByProperty) return "phrase";
144
+ const head = clauseHead(segment);
145
+ if (!head) return "phrase";
146
+ if (BOOLEAN_JOIN.test(segment) || !/\s/.test(segment)) return "clause";
147
+ return "restriction-value";
148
+ }
149
+ function rewriteMailSearchQuotes(expr) {
150
+ let out = "";
151
+ let i = 0;
152
+ while (i < expr.length) {
153
+ if (expr[i] === "\\" && expr[i + 1] === "\\") {
154
+ out += "\\\\";
155
+ i += 2;
156
+ continue;
157
+ }
158
+ if (expr[i] === "\\" && expr[i + 1] === '"') {
159
+ out += '\\"';
160
+ i += 2;
161
+ continue;
162
+ }
163
+ if (expr[i] !== '"') {
164
+ out += expr[i];
165
+ i += 1;
166
+ continue;
167
+ }
168
+ const run = readQuotedSegment(expr, i);
169
+ const segment = run ? run.segment : balanceTrailingSlashes(expr.slice(i + 1));
170
+ const introducedByProperty = i > 0 && expr[i - 1] === ":";
171
+ switch (classifyRun(segment, introducedByProperty)) {
172
+ case "clause":
173
+ out += segment;
174
+ break;
175
+ case "restriction-value": {
176
+ const head = clauseHead(segment);
177
+ const valueAt = head[0].length - 1;
178
+ out += `${segment.slice(0, valueAt)}\\"${segment.slice(valueAt)}\\"`;
179
+ break;
180
+ }
181
+ default:
182
+ out += `\\"${segment}\\"`;
183
+ }
184
+ if (!run) break;
185
+ i = run.end + 1;
186
+ }
187
+ return balanceTrailingSlashes(out.trim());
188
+ }
189
+ function normalizeSearchQueryParam(queryParams, path2, toolAlias) {
190
+ if (!isOutlookMailPath(path2)) return;
191
+ const raw = queryParams["$search"];
192
+ if (raw === void 0) return;
193
+ const trimmed = raw.trim();
194
+ const noSearchableText = () => {
195
+ logger.warn(`Refusing ${toolAlias}: '$search' has no searchable text`);
196
+ return {
197
+ content: [
198
+ {
199
+ type: "text",
200
+ text: JSON.stringify({
201
+ error: "invalid_search",
202
+ tool: toolAlias,
203
+ message: 'The $search parameter has no searchable text. Supply a KQL expression such as "from:john" or "subject:budget", or omit $search to list messages unfiltered.'
204
+ })
205
+ }
206
+ ],
207
+ isError: true
208
+ };
209
+ };
210
+ if (trimmed === "" || /^["'\s]+$/.test(trimmed)) return noSearchableText();
211
+ let expr = trimmed;
212
+ if (expr.startsWith('"')) {
213
+ const whole = readQuotedSegment(expr, 0);
214
+ if (!whole) expr = expr.slice(1);
215
+ else if (whole.end === expr.length - 1) expr = whole.segment;
216
+ }
217
+ const inner = rewriteMailSearchQuotes(expr);
218
+ if (inner === "") return noSearchableText();
219
+ const normalized = `"${inner}"`;
220
+ if (normalized !== raw) {
221
+ logger.info(`Auto-corrected parameter '$search': normalized KQL quoting to ${normalized}`);
222
+ queryParams["$search"] = normalized;
223
+ }
224
+ }
83
225
  const DEFAULT_MAX_ITEMS = 1e4;
84
226
  function isConfirmGateEnabled() {
85
227
  return process.env.MS365_MCP_REQUIRE_CONFIRM === "true";
@@ -339,6 +481,68 @@ async function checkAccountParamInBearerMode(accountParam, authManager) {
339
481
  if (bearerIdentity && bearerIdentity.toLowerCase() === accountParam.toLowerCase()) return null;
340
482
  return `The 'account' parameter is not supported in HTTP/OAuth mode: every request uses the identity of the connecting client's bearer token` + (bearerIdentity ? ` ('${bearerIdentity}')` : "") + `, so account switching is not possible. To act as '${accountParam}', reconnect the MCP client authenticated as that account, or run the server in stdio mode (or HTTP with --trust-proxy-auth) where cached accounts are available.`;
341
483
  }
484
+ async function mintDownloadUrl(target, accountParam, authManager) {
485
+ const minting = getAttachmentMinting();
486
+ if (!minting) return null;
487
+ if (authManager?.isOAuthModeEnabled() || getRequestTokens()) {
488
+ return {
489
+ content: [
490
+ {
491
+ type: "text",
492
+ text: JSON.stringify({
493
+ error: "Server-minted download URLs are unavailable when Graph identity comes from the request (OAuth, OBO, or bearer mode): the URL is redeemed later without an Authorization header, so the bytes would be fetched as a different identity than the one that asked for them. Use download-bytes."
494
+ })
495
+ }
496
+ ],
497
+ isError: true
498
+ };
499
+ }
500
+ const accountModeError = await checkAccountParamInBearerMode(accountParam, authManager);
501
+ if (accountModeError) {
502
+ return {
503
+ content: [{ type: "text", text: JSON.stringify({ error: accountModeError }) }],
504
+ isError: true
505
+ };
506
+ }
507
+ if (!isPlainGraphPath(target)) {
508
+ return {
509
+ content: [
510
+ {
511
+ type: "text",
512
+ text: JSON.stringify({
513
+ error: 'target must be a plain relative Graph path: no fragment, no query, no "." or ".." segments, and no percent-encoded separators.'
514
+ })
515
+ }
516
+ ],
517
+ isError: true
518
+ };
519
+ }
520
+ let ticket;
521
+ try {
522
+ ticket = minting.store.mint(target, accountParam);
523
+ } catch (error) {
524
+ if (error instanceof TicketStoreFullError) {
525
+ return {
526
+ content: [{ type: "text", text: JSON.stringify({ error: error.message }) }],
527
+ isError: true
528
+ };
529
+ }
530
+ throw error;
531
+ }
532
+ return {
533
+ content: [
534
+ {
535
+ type: "text",
536
+ text: JSON.stringify({
537
+ downloadUrl: buildAttachmentUrl(minting.config, ticket.id),
538
+ expiresAt: new Date(ticket.expiresAtMs).toISOString(),
539
+ singleUse: true,
540
+ note: "Served by this server, not by Microsoft Graph. Valid for one fetch until it expires."
541
+ })
542
+ }
543
+ ]
544
+ };
545
+ }
342
546
  const UTILITY_TOOLS = [
343
547
  {
344
548
  name: "parse-teams-url",
@@ -583,7 +787,7 @@ const UTILITY_TOOLS = [
583
787
  method: "GET",
584
788
  path: "tool:get-download-url",
585
789
  searchKeywords: "download file download drive file download onedrive file sharepoint file download large drive file large sharepoint file large file out-of-band download pre-authenticated url",
586
- description: "Resolve a short-lived, pre-authenticated download URL for Microsoft Graph binary content that exposes one (drive/SharePoint file content). The returned URL streams the bytes with NO Authorization header, so the client can fetch it straight to disk (e.g. curl) without round-tripping base64 through the agent context. Prefer this over download-bytes for any file above a few KB or any bulk download. Returns { downloadUrl, name?, size?, contentType? }. NOTE: mail file attachments (/messages/{id}/attachments/{id}/$value) and meeting recordings do NOT expose a pre-authenticated URL \u2014 Graph offers no such link for them; use download-bytes for small ones.",
790
+ description: "Resolve a short-lived, pre-authenticated download URL for Microsoft Graph binary content that exposes one (drive/SharePoint file content). The returned URL streams the bytes with NO Authorization header, so the client can fetch it straight to disk (e.g. curl) without round-tripping base64 through the agent context. Prefer this over download-bytes for any file above a few KB or any bulk download. Returns { downloadUrl, name?, size?, contentType? }. Mail file attachments (/messages/{id}/attachments/{id}/$value), meeting recordings and other $value byte endpoints have no pre-authenticated URL from Graph itself, but call this tool for them anyway: a server running with --enable-attachment-urls mints its own single-use URL for them, and one without it answers with the reason and points at download-bytes.",
587
791
  readOnlyHint: true,
588
792
  openWorldHint: true,
589
793
  buildSchema: (ctx) => {
@@ -642,6 +846,8 @@ const UTILITY_TOOLS = [
642
846
  }
643
847
  const pathPart = target.replace(/\/+$/, "");
644
848
  if (/^(\/me|\/users\/[^/]+)\/messages\/[^/]+\/attachments\//.test(pathPart) || /^(\/me|\/users\/[^/]+)\/events\/[^/]+\/attachments\//.test(pathPart) || /^\/groups\/[^/]+\/messages\/[^/]+\/attachments\//.test(pathPart) || /^\/groups\/[^/]+\/events\/[^/]+\/attachments\//.test(pathPart)) {
849
+ const minted = await mintDownloadUrl(pathPart, accountParam, authManager);
850
+ if (minted) return minted;
645
851
  return {
646
852
  content: [
647
853
  {
@@ -657,6 +863,8 @@ const UTILITY_TOOLS = [
657
863
  if (/^(\/me|\/users\/[^/]+)\/onlineMeetings\/[^/]+\/recordings\/[^/]+(?:\/content)?$/.test(
658
864
  pathPart
659
865
  ) || /^\/communications\/calls\/[^/]+\/recordings\/[^/]+(?:\/content)?$/.test(pathPart)) {
866
+ const minted = await mintDownloadUrl(pathPart, accountParam, authManager);
867
+ if (minted) return minted;
660
868
  return {
661
869
  content: [
662
870
  {
@@ -670,6 +878,8 @@ const UTILITY_TOOLS = [
670
878
  };
671
879
  }
672
880
  if (pathPart.endsWith("/$value")) {
881
+ const minted = await mintDownloadUrl(pathPart, accountParam, authManager);
882
+ if (minted) return minted;
673
883
  return {
674
884
  content: [
675
885
  {
@@ -739,6 +949,8 @@ const UTILITY_TOOLS = [
739
949
  }
740
950
  const downloadUrl = item?.["@microsoft.graph.downloadUrl"];
741
951
  if (!downloadUrl) {
952
+ const minted = await mintDownloadUrl(`${itemPath}/content`, accountParam, authManager);
953
+ if (minted) return minted;
742
954
  return {
743
955
  content: [
744
956
  {
@@ -1037,6 +1249,8 @@ async function executeGraphTool(tool, config, graphClient, params, authManager)
1037
1249
  delete queryParams["$top"];
1038
1250
  }
1039
1251
  clampTopQueryParam(queryParams);
1252
+ const searchError = normalizeSearchQueryParam(queryParams, tool.path, tool.alias);
1253
+ if (searchError) return searchError;
1040
1254
  const preferValues = [];
1041
1255
  if (config?.supportsTimezone && params.timezone) {
1042
1256
  preferValues.push(`outlook.timezone="${params.timezone}"`);
@@ -0,0 +1,15 @@
1
+ let current = null;
2
+ function configureAttachmentMinting(minting) {
3
+ current = minting;
4
+ }
5
+ function getAttachmentMinting() {
6
+ return current;
7
+ }
8
+ function resetAttachmentMinting() {
9
+ current = null;
10
+ }
11
+ export {
12
+ configureAttachmentMinting,
13
+ getAttachmentMinting,
14
+ resetAttachmentMinting
15
+ };
@@ -0,0 +1,89 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { ATTACHMENT_ROUTE } from "./attachment-url-config.js";
3
+ import { signUrl } from "./url-signing.js";
4
+ const TICKET_PARAM = "t";
5
+ function buildAttachmentUrl(config, ticketId, nowMs = Date.now()) {
6
+ const url = new URL(ATTACHMENT_ROUTE, config.base);
7
+ url.searchParams.set(TICKET_PARAM, ticketId);
8
+ return signUrl(
9
+ url.toString(),
10
+ { key: config.key, keyId: config.keyId, ttlSeconds: config.ttlSeconds },
11
+ nowMs
12
+ );
13
+ }
14
+ const PROBE_ORIGIN = "https://graph.invalid";
15
+ const PROBE_PREFIX = "/v1.0";
16
+ function isPlainGraphPath(target) {
17
+ if (!target.startsWith("/")) return false;
18
+ if (target.includes("//")) return false;
19
+ let resolved;
20
+ try {
21
+ resolved = new URL(PROBE_ORIGIN + PROBE_PREFIX + target);
22
+ } catch {
23
+ return false;
24
+ }
25
+ return resolved.origin === PROBE_ORIGIN && resolved.search === "" && resolved.hash === "" && resolved.pathname === PROBE_PREFIX + target;
26
+ }
27
+ const MAX_LIVE_TICKETS = 256;
28
+ const TICKET_BYTES = 32;
29
+ class TicketStoreFullError extends Error {
30
+ constructor(limit) {
31
+ super(`No ticket slots available (limit ${limit}); retry once outstanding tickets expire.`);
32
+ this.limit = limit;
33
+ this.name = "TicketStoreFullError";
34
+ }
35
+ }
36
+ class AttachmentTicketStore {
37
+ constructor(ttlSeconds) {
38
+ this.ttlSeconds = ttlSeconds;
39
+ this.tickets = /* @__PURE__ */ new Map();
40
+ }
41
+ /** Drop every expired ticket. Called before each mint and each redemption. */
42
+ sweep(nowMs) {
43
+ for (const [id, ticket] of this.tickets) {
44
+ if (ticket.expiresAtMs <= nowMs) this.tickets.delete(id);
45
+ }
46
+ }
47
+ mint(target, accountName, nowMs = Date.now()) {
48
+ this.sweep(nowMs);
49
+ if (this.tickets.size >= MAX_LIVE_TICKETS) {
50
+ throw new TicketStoreFullError(MAX_LIVE_TICKETS);
51
+ }
52
+ const id = randomBytes(TICKET_BYTES).toString("base64url");
53
+ const expiresAtMs = nowMs + this.ttlSeconds * 1e3;
54
+ this.tickets.set(id, { target, accountName, expiresAtMs });
55
+ return { id, expiresAtMs };
56
+ }
57
+ /**
58
+ * Return the ticket and burn it, or undefined.
59
+ *
60
+ * One `undefined` for every failure -- unknown id, already redeemed, expired.
61
+ * The caller answers 404 to all three, so a probe cannot use the response to
62
+ * tell "never existed" from "already used", which would confirm a guessed id.
63
+ *
64
+ * The delete happens before the value is returned rather than in the caller,
65
+ * so an exception on the streaming path cannot leave a redeemed ticket live.
66
+ */
67
+ redeem(id, nowMs = Date.now()) {
68
+ this.sweep(nowMs);
69
+ const ticket = this.tickets.get(id);
70
+ if (!ticket) return void 0;
71
+ this.tickets.delete(id);
72
+ return ticket;
73
+ }
74
+ /** Live ticket count, for tests and diagnostics. Never logged with ids. */
75
+ size(nowMs = Date.now()) {
76
+ this.sweep(nowMs);
77
+ return this.tickets.size;
78
+ }
79
+ clear() {
80
+ this.tickets.clear();
81
+ }
82
+ }
83
+ export {
84
+ AttachmentTicketStore,
85
+ TICKET_PARAM,
86
+ TicketStoreFullError,
87
+ buildAttachmentUrl,
88
+ isPlainGraphPath
89
+ };
@@ -0,0 +1,104 @@
1
+ import { readFileSync } from "node:fs";
2
+ const ATTACHMENT_ROUTE = "/attachment";
3
+ const DEFAULT_TTL_SECONDS = 120;
4
+ const MAX_TTL_SECONDS = 300;
5
+ class AttachmentUrlConfigError extends Error {
6
+ constructor(message) {
7
+ super(message);
8
+ this.name = "AttachmentUrlConfigError";
9
+ }
10
+ }
11
+ const PYTHON_WHITESPACE = " \n\v\f\r \x85\xA0\u1680\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200A\u2028\u2029\u202F\u205F\u3000";
12
+ function pythonStrip(value) {
13
+ let start = 0;
14
+ let end = value.length;
15
+ while (start < end && PYTHON_WHITESPACE.includes(value[start])) start += 1;
16
+ while (end > start && PYTHON_WHITESPACE.includes(value[end - 1])) end -= 1;
17
+ return value.slice(start, end);
18
+ }
19
+ function readKey(env) {
20
+ const keyFile = env.MS365_MCP_ATTACHMENT_URL_KEY_FILE;
21
+ if (keyFile) {
22
+ try {
23
+ return pythonStrip(readFileSync(keyFile, "utf8"));
24
+ } catch {
25
+ throw new AttachmentUrlConfigError(
26
+ `MS365_MCP_ATTACHMENT_URL_KEY_FILE could not be read: ${keyFile}`
27
+ );
28
+ }
29
+ }
30
+ return env.MS365_MCP_ATTACHMENT_URL_KEY ?? "";
31
+ }
32
+ function loadAttachmentUrlConfig(enabled, env = process.env) {
33
+ if (!enabled) return null;
34
+ const base = env.MS365_MCP_ATTACHMENT_URL_BASE ?? "";
35
+ if (!base) {
36
+ throw new AttachmentUrlConfigError(
37
+ "--enable-attachment-urls requires MS365_MCP_ATTACHMENT_URL_BASE (the origin a document-conversion sidecar will fetch, e.g. http://m365-mcp:3000). This is deliberately not MS365_MCP_PUBLIC_URL: that one is browser-facing for OAuth, while this is reached server-to-server and is commonly a container address. With --attachment-port, name that port here, not the --http one: the route is not served on the MCP port at all in that mode."
38
+ );
39
+ }
40
+ let parsedBase;
41
+ try {
42
+ parsedBase = new URL(base);
43
+ } catch {
44
+ throw new AttachmentUrlConfigError(
45
+ `MS365_MCP_ATTACHMENT_URL_BASE is not a valid absolute URL: ${base}`
46
+ );
47
+ }
48
+ if (parsedBase.protocol !== "http:" && parsedBase.protocol !== "https:") {
49
+ throw new AttachmentUrlConfigError(
50
+ `MS365_MCP_ATTACHMENT_URL_BASE must be http or https, got ${parsedBase.protocol}`
51
+ );
52
+ }
53
+ if (parsedBase.hostname.startsWith("[")) {
54
+ throw new AttachmentUrlConfigError(
55
+ `MS365_MCP_ATTACHMENT_URL_BASE must not be an IPv6 literal: the URL signature covers the host, and this runtime and the verifying sidecar normalise IPv6 spellings differently, so every minted URL would be refused. Use a hostname (a container or service name) instead of ${parsedBase.hostname}.`
56
+ );
57
+ }
58
+ if (parsedBase.search || parsedBase.hash) {
59
+ throw new AttachmentUrlConfigError(
60
+ "MS365_MCP_ATTACHMENT_URL_BASE must not carry a query string or fragment."
61
+ );
62
+ }
63
+ const key = readKey(env);
64
+ if (!key) {
65
+ throw new AttachmentUrlConfigError(
66
+ "--enable-attachment-urls requires MS365_MCP_ATTACHMENT_URL_KEY or MS365_MCP_ATTACHMENT_URL_KEY_FILE -- the HMAC key shared with the sidecar that will verify the minted URL."
67
+ );
68
+ }
69
+ for (let index = 0; index < key.length; index += 1) {
70
+ const code = key.charCodeAt(index);
71
+ if (code < 32 || code === 127) {
72
+ throw new AttachmentUrlConfigError(
73
+ `The attachment URL signing key contains a control character at offset ${index} (value not shown).`
74
+ );
75
+ }
76
+ }
77
+ const keyId = env.MS365_MCP_ATTACHMENT_URL_KEY_ID || "1";
78
+ const ttlRaw = env.MS365_MCP_ATTACHMENT_URL_TTL_S;
79
+ let ttlSeconds = DEFAULT_TTL_SECONDS;
80
+ if (ttlRaw !== void 0 && ttlRaw !== "") {
81
+ if (!/^\d+$/.test(ttlRaw)) {
82
+ throw new AttachmentUrlConfigError(
83
+ `MS365_MCP_ATTACHMENT_URL_TTL_S must be a positive integer, got ${JSON.stringify(ttlRaw)}`
84
+ );
85
+ }
86
+ ttlSeconds = Number(ttlRaw);
87
+ if (ttlSeconds <= 0 || ttlSeconds > MAX_TTL_SECONDS) {
88
+ throw new AttachmentUrlConfigError(
89
+ `MS365_MCP_ATTACHMENT_URL_TTL_S must be between 1 and ${MAX_TTL_SECONDS}, got ${ttlSeconds}`
90
+ );
91
+ }
92
+ }
93
+ return {
94
+ base: parsedBase.origin,
95
+ key,
96
+ keyId,
97
+ ttlSeconds
98
+ };
99
+ }
100
+ export {
101
+ ATTACHMENT_ROUTE,
102
+ AttachmentUrlConfigError,
103
+ loadAttachmentUrlConfig
104
+ };
@@ -29,7 +29,7 @@ function isFetchAllPagesApplicable(tool) {
29
29
  return tool.method.toUpperCase() === "GET" && tool.path.includes("/") && paginationAllowed();
30
30
  }
31
31
  const FILTER_PARAM_DESCRIPTION = "OData filter expression. Add $count=true for advanced filters (flag/flagStatus, contains()). Cannot combine with $search.";
32
- const SEARCH_PARAM_DESCRIPTION = "KQL search query \u2014 wrap value in double quotes. Cannot combine with $filter.";
32
+ const SEARCH_PARAM_DESCRIPTION = "KQL search query in one pair of double quotes; directory (users/groups) instead quotes each clause with no outer pair. Cannot combine with $filter.";
33
33
  const SELECT_PARAM_DESCRIPTION = "Comma-separated fields to return, e.g. id,subject,from,receivedDateTime";
34
34
  const EXPAND_PARAM_DESCRIPTION = 'Navigation properties to inline, e.g. attachments on a message or event. Only navigation properties can be expanded: expanding a non-navigation property such as a message body fails with "Parsing OData Select and Expand failed", and an unsupported value may be ignored rather than reported. Request ordinary fields with $select instead.';
35
35
  const ORDERBY_PARAM_DESCRIPTION = "Sort expression, e.g. receivedDateTime desc";
@@ -0,0 +1,81 @@
1
+ import { createHmac } from "node:crypto";
2
+ function quoteAll(value) {
3
+ return Array.from(new TextEncoder().encode(value)).map((byte) => {
4
+ const char = String.fromCharCode(byte);
5
+ if (/[A-Za-z0-9_.\-~]/.test(char)) return char;
6
+ return "%" + byte.toString(16).toUpperCase().padStart(2, "0");
7
+ }).join("");
8
+ }
9
+ function unquotePlus(value) {
10
+ const withSpaces = value.replace(/\+/g, " ");
11
+ const bytes = [];
12
+ for (let i = 0; i < withSpaces.length; i += 1) {
13
+ const char = withSpaces[i];
14
+ if (char === "%" && /^[0-9A-Fa-f]{2}$/.test(withSpaces.slice(i + 1, i + 3))) {
15
+ bytes.push(parseInt(withSpaces.slice(i + 1, i + 3), 16));
16
+ i += 2;
17
+ continue;
18
+ }
19
+ for (const byte of new TextEncoder().encode(char)) bytes.push(byte);
20
+ }
21
+ return new TextDecoder("utf-8", { fatal: false }).decode(new Uint8Array(bytes));
22
+ }
23
+ function compareByCodePoint(a, b) {
24
+ const left = Array.from(a);
25
+ const right = Array.from(b);
26
+ const shared = Math.min(left.length, right.length);
27
+ for (let i = 0; i < shared; i += 1) {
28
+ const diff = left[i].codePointAt(0) - right[i].codePointAt(0);
29
+ if (diff !== 0) return diff;
30
+ }
31
+ return left.length - right.length;
32
+ }
33
+ const RESERVED_PARAMS = /* @__PURE__ */ new Set(["dgk", "dgx", "dgs"]);
34
+ function parseQsl(query) {
35
+ const pairs = [];
36
+ for (const field of query.split("&")) {
37
+ if (!field) continue;
38
+ const eq = field.indexOf("=");
39
+ const rawName = eq === -1 ? field : field.slice(0, eq);
40
+ const rawValue = eq === -1 ? "" : field.slice(eq + 1);
41
+ pairs.push([unquotePlus(rawName), unquotePlus(rawValue)]);
42
+ }
43
+ return pairs;
44
+ }
45
+ function canonicalQuery(query) {
46
+ const pairs = parseQsl(query).filter(([name]) => !RESERVED_PARAMS.has(name));
47
+ pairs.sort((a, b) => compareByCodePoint(a[0], b[0]) || compareByCodePoint(a[1], b[1]));
48
+ return pairs.map(([name, value]) => `${quoteAll(name)}=${quoteAll(value)}`).join("&");
49
+ }
50
+ function canonicalString(url, expiry) {
51
+ const parsed = new URL(url);
52
+ const scheme = parsed.protocol.replace(/:$/, "").toLowerCase();
53
+ const host = parsed.hostname.toLowerCase().replace(/^\[(.+)\]$/, "$1");
54
+ const port = parsed.port === "" ? scheme === "https" ? 443 : 80 : Number(parsed.port);
55
+ const path = parsed.pathname === "" ? "/" : parsed.pathname;
56
+ const query = canonicalQuery(parsed.search.replace(/^\?/, ""));
57
+ return ["v1", scheme, host, String(port), path, query, expiry].join("\n");
58
+ }
59
+ function digest(key, message) {
60
+ return createHmac("sha256", Buffer.from(key, "utf8")).update(Buffer.from(message, "utf8")).digest("base64url");
61
+ }
62
+ function signUrl(url, config, nowMs = Date.now()) {
63
+ const expiry = String(Math.floor(nowMs / 1e3) + config.ttlSeconds);
64
+ const signature = digest(config.key, canonicalString(url, expiry));
65
+ const parsed = new URL(url);
66
+ const kept = parseQsl(parsed.search.replace(/^\?/, "")).filter(
67
+ ([name]) => !RESERVED_PARAMS.has(name)
68
+ );
69
+ kept.push(["dgk", config.keyId], ["dgx", expiry], ["dgs", signature]);
70
+ parsed.search = kept.map(([name, value]) => `${quoteAll(name)}=${quoteAll(value)}`).join("&");
71
+ return parsed.toString();
72
+ }
73
+ export {
74
+ canonicalString,
75
+ compareByCodePoint,
76
+ digest,
77
+ parseQsl,
78
+ quoteAll,
79
+ signUrl,
80
+ unquotePlus
81
+ };