@knowttl/mg-axi 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/README.md +1138 -0
- package/dist/api.js +953 -0
- package/dist/app-auth.js +47 -0
- package/dist/auth.js +59 -0
- package/dist/bin/mg-axi.js +7 -0
- package/dist/catalogue.js +2191 -0
- package/dist/cli.js +1314 -0
- package/dist/docs.js +559 -0
- package/dist/doctor.js +75 -0
- package/dist/entra-access-reviews.js +425 -0
- package/dist/entra-apps.js +374 -0
- package/dist/entra-audit-logs.js +381 -0
- package/dist/entra-auth-methods.js +296 -0
- package/dist/entra-ca-policy-update.js +353 -0
- package/dist/entra-certificate-auth.js +384 -0
- package/dist/entra-conditional-access.js +317 -0
- package/dist/entra-contacts.js +827 -0
- package/dist/entra-contracts.js +252 -0
- package/dist/entra-custom-security-attributes.js +464 -0
- package/dist/entra-data-policy-operations.js +274 -0
- package/dist/entra-delegated-admin.js +468 -0
- package/dist/entra-deleted-items.js +461 -0
- package/dist/entra-directory-objects.js +274 -0
- package/dist/entra-directory.js +411 -0
- package/dist/entra-domains.js +399 -0
- package/dist/entra-entitlement-management.js +645 -0
- package/dist/entra-federation-configurations.js +290 -0
- package/dist/entra-grants.js +272 -0
- package/dist/entra-group-lifecycle.js +349 -0
- package/dist/entra-group-member-add.js +278 -0
- package/dist/entra-groups.js +336 -0
- package/dist/entra-identity-providers.js +297 -0
- package/dist/entra-lifecycle-workflows.js +673 -0
- package/dist/entra-multi-tenant-organization.js +309 -0
- package/dist/entra-on-premises-synchronization.js +231 -0
- package/dist/entra-organization.js +398 -0
- package/dist/entra-risk-dismiss.js +191 -0
- package/dist/entra-risk.js +404 -0
- package/dist/entra-roles.js +310 -0
- package/dist/entra-subscriptions.js +289 -0
- package/dist/entra-tenant-information.js +152 -0
- package/dist/entra-terms-of-use.js +380 -0
- package/dist/entra-user-revoke-sessions.js +170 -0
- package/dist/entra-user-update.js +170 -0
- package/dist/entra-users.js +211 -0
- package/dist/graph-session.js +1034 -0
- package/dist/msal-app-provider.js +42 -0
- package/dist/msal-provider.js +82 -0
- package/dist/mutations.js +529 -0
- package/dist/profiles.js +139 -0
- package/dist/setup.js +116 -0
- package/dist/version.js +2 -0
- package/docs/coverage.md +1028 -0
- package/inventory/operations.json +4 -0
- package/inventory/schema.json +71 -0
- package/package.json +32 -0
- package/skills/mg-axi/SKILL.md +293 -0
|
@@ -0,0 +1,1034 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
4
|
+
import { AxiError } from "axi-sdk-js";
|
|
5
|
+
import { validateApplicationProfile, validateDelegatedProfile, validateProfile, } from "./profiles.js";
|
|
6
|
+
// CORE-01: the single path that owns Graph URL construction, operation
|
|
7
|
+
// authorization, credential attachment and response validation. Command
|
|
8
|
+
// handlers call execute() with a catalogued inventory operation and bound
|
|
9
|
+
// parameters; they never see tokens and never touch a raw fetch.
|
|
10
|
+
// Redaction marker and secret shapes follow az-axi's redact.ts conventions;
|
|
11
|
+
// Graph error pairs never carry az-axi's keyName/value envelope, so only the
|
|
12
|
+
// key-name and secret-value rules are adapted here.
|
|
13
|
+
export const REDACTED = "***redacted***";
|
|
14
|
+
export const SAFE_CREDENTIAL_FIELDS = ["keyId", "displayName", "startDateTime", "endDateTime"];
|
|
15
|
+
// READ-04 protected values: authentication-method phone numbers are PII the
|
|
16
|
+
// server itself masks for the Authentication Administrator role, so the
|
|
17
|
+
// session replaces them with the marker for every role before collection
|
|
18
|
+
// buffering, execute output and cursor decode.
|
|
19
|
+
export const GRAPH_HOST = "graph.microsoft.com";
|
|
20
|
+
const READ_SCOPES = new Set([
|
|
21
|
+
"Agreement.Read.All",
|
|
22
|
+
"AgreementAcceptance.Read",
|
|
23
|
+
"AgreementAcceptance.Read.All",
|
|
24
|
+
"AccessReview.Read.All",
|
|
25
|
+
"AdministrativeUnit.Read.All",
|
|
26
|
+
"Application.Read.All",
|
|
27
|
+
"AuditLog.Read.All",
|
|
28
|
+
"CustomSecAttributeDefinition.Read.All",
|
|
29
|
+
"CrossTenantInformation.ReadBasic.All",
|
|
30
|
+
"DelegatedAdminRelationship.Read.All",
|
|
31
|
+
"Device.Read.All",
|
|
32
|
+
"Directory.Read.All",
|
|
33
|
+
"Domain.Read.All",
|
|
34
|
+
"EntitlementManagement.Read.All",
|
|
35
|
+
"Group.Read.All",
|
|
36
|
+
"GroupMember.Read.All",
|
|
37
|
+
"GroupSettings.Read.All",
|
|
38
|
+
"GroupMember.ReadBasic.All",
|
|
39
|
+
"IdentityProvider.Read.All",
|
|
40
|
+
"IdentityRiskEvent.Read.All",
|
|
41
|
+
"IdentityRiskyUser.Read.All",
|
|
42
|
+
"LicenseAssignment.Read.All",
|
|
43
|
+
"LifecycleWorkflows-Reports.Read.All",
|
|
44
|
+
"LifecycleWorkflows-Workflow.Read.All",
|
|
45
|
+
"LifecycleWorkflows-Workflow.ReadBasic.All",
|
|
46
|
+
"LifecycleWorkflows.Read.All",
|
|
47
|
+
"Member.Read.Hidden",
|
|
48
|
+
"MultiTenantOrganization.Read.All",
|
|
49
|
+
"MultiTenantOrganization.ReadBasic.All",
|
|
50
|
+
"OnPremDirectorySynchronization.Read.All",
|
|
51
|
+
"OrgContact.Read.All",
|
|
52
|
+
"Organization.Read.All",
|
|
53
|
+
"OrganizationalBranding.Read.All",
|
|
54
|
+
"Policy.Read.All",
|
|
55
|
+
"Policy.Read.AuthenticationMethod",
|
|
56
|
+
"Policy.Read.ConditionalAccess",
|
|
57
|
+
"PrivilegedEligibilitySchedule.Read.AzureADGroup",
|
|
58
|
+
"PublicKeyInfrastructure.Read.All",
|
|
59
|
+
"RoleAssignmentSchedule.Read.Directory",
|
|
60
|
+
"RoleEligibilitySchedule.Read.Directory",
|
|
61
|
+
"RoleManagement.Read.Directory",
|
|
62
|
+
"Synchronization.Read.All",
|
|
63
|
+
"User.Read",
|
|
64
|
+
"User.Export.All",
|
|
65
|
+
"User.Read.All",
|
|
66
|
+
"User.ReadBasic.All",
|
|
67
|
+
"UserAuthenticationMethod.Read",
|
|
68
|
+
"UserAuthenticationMethod.Read.All",
|
|
69
|
+
].map(scope => `https://${GRAPH_HOST}/${scope}`));
|
|
70
|
+
// Conservative read-query allowlist. Per-operation review (READ slices) can
|
|
71
|
+
// extend it; unknown keys fail closed here. $search/$count=true need eventual
|
|
72
|
+
// consistency (see checkQueryContext); $skiptoken carries paging state.
|
|
73
|
+
export const ALLOWED_QUERY_KEYS = ["$select", "$filter", "$top", "$orderby", "$count", "$skiptoken", "$search", "$expand"];
|
|
74
|
+
const ALLOWED_KEYS = new Set(ALLOWED_QUERY_KEYS);
|
|
75
|
+
// Only these dispositions may execute. `scheduled` stays executable at this
|
|
76
|
+
// layer so READ slices need no session change; catalogue/CLI gating still
|
|
77
|
+
// keeps unshipped commands out of reach. Everything else fails closed.
|
|
78
|
+
const EXECUTABLE_DISPOSITIONS = new Set(["named-command", "reviewed-raw-read", "scheduled"]);
|
|
79
|
+
const MAX_REDIRECTS = 3;
|
|
80
|
+
const MAX_QUERY_VALUE = 1024;
|
|
81
|
+
// CORE-02 ceilings: single Retry-After waits above this never retry; the
|
|
82
|
+
// collection budget bounds total requests, bytes and wall-clock time.
|
|
83
|
+
export const MAX_RETRY_AFTER_MS = 10_000;
|
|
84
|
+
export const DEFAULT_MAX_REQUESTS = 20;
|
|
85
|
+
export const DEFAULT_MAX_BYTES = 5_000_000;
|
|
86
|
+
export const MAX_CURSOR_BYTES = 16_000_000;
|
|
87
|
+
export const DEFAULT_DEADLINE_MS = 30_000;
|
|
88
|
+
// Bounded digest of visited continuations carried in cursors so cycles
|
|
89
|
+
// across resumes end as partial, never complete.
|
|
90
|
+
export const MAX_SEEN = 64;
|
|
91
|
+
export const systemClock = {
|
|
92
|
+
now: () => Date.now(),
|
|
93
|
+
sleep: (ms, signal) => (signal ? delay(ms, undefined, { signal }) : delay(ms)),
|
|
94
|
+
};
|
|
95
|
+
let operationsById;
|
|
96
|
+
function findOperation(id) {
|
|
97
|
+
if (!operationsById) {
|
|
98
|
+
const inventory = JSON.parse(readFileSync(new URL("../inventory/operations.json", import.meta.url), "utf8"));
|
|
99
|
+
operationsById = new Map(inventory.operations.map(row => [row.id, row]));
|
|
100
|
+
}
|
|
101
|
+
return operationsById.get(id);
|
|
102
|
+
}
|
|
103
|
+
export function resolveSessionOperation(version, method, route) {
|
|
104
|
+
const id = `${version}:${method}:${route}`;
|
|
105
|
+
const operation = findOperation(id);
|
|
106
|
+
if (!operation) {
|
|
107
|
+
throw new AxiError(`Unknown catalogued Graph operation ${id}`, "VALIDATION_ERROR", [
|
|
108
|
+
"Use a route from inventory/operations.json; the session never builds uncatalogued URLs",
|
|
109
|
+
]);
|
|
110
|
+
}
|
|
111
|
+
return { ...operation };
|
|
112
|
+
}
|
|
113
|
+
const SECRET_KEY = /(password|passwd|secret|token|credential|sas|authorization|accountkey)/i;
|
|
114
|
+
const KEY_SUFFIX = /[a-z0-9](key|keys)$/i;
|
|
115
|
+
const SECRET_VALUE = [
|
|
116
|
+
/AccountKey=/i,
|
|
117
|
+
/SharedAccessKey/i,
|
|
118
|
+
/SharedAccessSignature/i,
|
|
119
|
+
/[?&]sig=/i,
|
|
120
|
+
/-----BEGIN/,
|
|
121
|
+
/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]*/,
|
|
122
|
+
];
|
|
123
|
+
function secretKey(key) {
|
|
124
|
+
if (key === "customAccountResetCredentialsUrl" || key === "customForgotMyPasswordText")
|
|
125
|
+
return false;
|
|
126
|
+
const name = key.toLowerCase().replace(/[-_]/g, "");
|
|
127
|
+
return name === "sas" || name === "authorization" || SECRET_KEY.test(name) || KEY_SUFFIX.test(name);
|
|
128
|
+
}
|
|
129
|
+
// Shared redaction for reads, mutation previews and audit metadata: secret
|
|
130
|
+
// key names and sentinel values become the marker before buffering, output
|
|
131
|
+
// or journaling. WRITE-00 reuses this so previews never leak secrets.
|
|
132
|
+
export function redactGraphValue(value) {
|
|
133
|
+
return redact(value);
|
|
134
|
+
}
|
|
135
|
+
function redact(value) {
|
|
136
|
+
if (typeof value === "string")
|
|
137
|
+
return SECRET_VALUE.some(pattern => pattern.test(value)) ? REDACTED : value;
|
|
138
|
+
if (Array.isArray(value))
|
|
139
|
+
return value.map(redact);
|
|
140
|
+
if (value === null || typeof value !== "object")
|
|
141
|
+
return value;
|
|
142
|
+
return Object.fromEntries(Object.entries(value)
|
|
143
|
+
.filter(([key]) => key !== "clientSecret" && key !== "certificateData")
|
|
144
|
+
.map(([key, child]) => {
|
|
145
|
+
if (key === "keyCredentials" || key === "passwordCredentials") {
|
|
146
|
+
const entries = Array.isArray(child) ? child : [];
|
|
147
|
+
return [key, entries
|
|
148
|
+
.filter(entry => entry !== null && typeof entry === "object" && !Array.isArray(entry))
|
|
149
|
+
.map(entry => redact(Object.fromEntries(SAFE_CREDENTIAL_FIELDS
|
|
150
|
+
.filter(field => Object.hasOwn(entry, field))
|
|
151
|
+
.map(field => [field, entry[field]]))))];
|
|
152
|
+
}
|
|
153
|
+
return [key, typeof child === "string" && (secretKey(key) || key === "phoneNumber" || key === "storageLocation") ? REDACTED : redact(child)];
|
|
154
|
+
}));
|
|
155
|
+
}
|
|
156
|
+
function splitPath(path) {
|
|
157
|
+
return path.split("/").filter(segment => segment.length > 0);
|
|
158
|
+
}
|
|
159
|
+
function bindingName(segment) {
|
|
160
|
+
return /^\{([^{}]+)\}$/.exec(segment)?.[1] ?? null;
|
|
161
|
+
}
|
|
162
|
+
// Match an authorized path against the operation route template, extracting
|
|
163
|
+
// the encoded binding for each placeholder. Literals compare
|
|
164
|
+
// case-insensitively, matching Graph routing; bindings stay exact.
|
|
165
|
+
// Function-argument segments match only for allowlisted operations: the
|
|
166
|
+
// literal head and closing paren compare case-insensitively while the bound
|
|
167
|
+
// middle stays exact for the authorizeUrl comparison below.
|
|
168
|
+
function matchFunctionSegment(route, slot, segment) {
|
|
169
|
+
const binding = functionBindingFor(route);
|
|
170
|
+
if (!binding || slot !== functionTemplate(binding))
|
|
171
|
+
return null;
|
|
172
|
+
const head = `${binding.functionName}(${binding.param}=`;
|
|
173
|
+
if (segment.length <= head.length + 1 || !segment.toLowerCase().startsWith(head.toLowerCase()) || !segment.endsWith(")"))
|
|
174
|
+
return null;
|
|
175
|
+
return { [binding.placeholder]: segment.slice(head.length, -1) };
|
|
176
|
+
}
|
|
177
|
+
function matchRoute(route, pathname) {
|
|
178
|
+
const template = splitPath(route);
|
|
179
|
+
const actual = splitPath(pathname);
|
|
180
|
+
if (template.length !== actual.length)
|
|
181
|
+
return null;
|
|
182
|
+
const params = Object.create(null);
|
|
183
|
+
for (let i = 0; i < template.length; i++) {
|
|
184
|
+
const slot = template[i];
|
|
185
|
+
const segment = actual[i];
|
|
186
|
+
const name = bindingName(slot);
|
|
187
|
+
if (!name && /[{}]/.test(slot)) {
|
|
188
|
+
const bound = matchFunctionSegment(route, slot, segment);
|
|
189
|
+
if (!bound)
|
|
190
|
+
return null;
|
|
191
|
+
Object.assign(params, bound);
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (name)
|
|
195
|
+
params[name] = segment;
|
|
196
|
+
else if (slot.toLowerCase() !== segment.toLowerCase())
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
return params;
|
|
200
|
+
}
|
|
201
|
+
// Initial sensitive-area map. Mail and file content stay denied unless the
|
|
202
|
+
// profile explicitly enables the area; consent and directory metadata are not
|
|
203
|
+
// content areas. Later slices refine this table with their reviewed contracts.
|
|
204
|
+
function sensitiveArea(route) {
|
|
205
|
+
const segments = splitPath(route).map(segment => segment.toLowerCase());
|
|
206
|
+
if (segments.some(segment => segment === "messages" || segment === "mailfolders"))
|
|
207
|
+
return "mail";
|
|
208
|
+
if (segments.some(segment => segment === "drive" || segment === "drives" || segment === "file" || segment === "files") || segments[0] === "shares")
|
|
209
|
+
return "files";
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
function checkOperation(operation) {
|
|
213
|
+
if (!operation ||
|
|
214
|
+
typeof operation.id !== "string" ||
|
|
215
|
+
typeof operation.method !== "string" ||
|
|
216
|
+
typeof operation.path !== "string" ||
|
|
217
|
+
!operation.path.startsWith("/") ||
|
|
218
|
+
typeof operation.version !== "string" ||
|
|
219
|
+
typeof operation.disposition !== "string" ||
|
|
220
|
+
!(typeof operation.owningSlice === "string" || operation.owningSlice === null)) {
|
|
221
|
+
throw new AxiError("Graph operations must be catalogued inventory rows", "VALIDATION_ERROR", [
|
|
222
|
+
"Resolve the operation from inventory/operations.json before executing it",
|
|
223
|
+
]);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const UNSAFE_PARAM = /[%/?\\{}()]/;
|
|
227
|
+
const CONTROL = /[\s\x00-\x1f\x7f]/;
|
|
228
|
+
function checkFunctionValue(value, what) {
|
|
229
|
+
if (typeof value !== "string" ||
|
|
230
|
+
!value.length ||
|
|
231
|
+
value.length > 256 ||
|
|
232
|
+
/[\x00-\x1f\x7f\s]/.test(value) ||
|
|
233
|
+
/[/\\?#$%]/.test(value)) {
|
|
234
|
+
throw new AxiError(`Invalid ${what}`, "VALIDATION_ERROR", [
|
|
235
|
+
"Bind one validated identifier per function argument; control characters, whitespace, separators and reserved characters are rejected",
|
|
236
|
+
]);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
function validateDomainName(value) {
|
|
240
|
+
checkFunctionValue(value, "domain name");
|
|
241
|
+
const labels = value.length > 253 ? [] : value.split(".");
|
|
242
|
+
if (!labels.length || labels.some(label => !/^[A-Za-z0-9]([A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/.test(label))) {
|
|
243
|
+
throw new AxiError("Invalid domain name", "VALIDATION_ERROR", [
|
|
244
|
+
"Pass a valid DNS name such as example.invalid; labels bind alphanumerics and hyphens without leading or trailing dots",
|
|
245
|
+
]);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
const GUID = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
|
|
249
|
+
function validateTenantId(value) {
|
|
250
|
+
checkFunctionValue(value, "tenant ID");
|
|
251
|
+
if (!GUID.test(value)) {
|
|
252
|
+
throw new AxiError("Invalid tenant ID", "VALIDATION_ERROR", [
|
|
253
|
+
"Pass the tenant GUID (8-4-4-4-12 hexadecimal digits); domain names belong on the domain-name lookup instead",
|
|
254
|
+
]);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
function validateCommerceSubscriptionId(value) {
|
|
258
|
+
checkFunctionValue(value, "commerce subscription ID");
|
|
259
|
+
if (!/^[A-Za-z0-9_-]{1,64}$/.test(value)) {
|
|
260
|
+
throw new AxiError("Invalid commerce subscription ID", "VALIDATION_ERROR", [
|
|
261
|
+
"Pass the commerce-system subscription identifier (letters, digits, dash and underscore, up to 64 characters)",
|
|
262
|
+
]);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
const FUNCTION_ARGUMENT_BINDINGS = [
|
|
266
|
+
{
|
|
267
|
+
route: "/tenantRelationships/findTenantInformationByDomainName(domainName='{domainName}')",
|
|
268
|
+
functionName: "findTenantInformationByDomainName",
|
|
269
|
+
param: "domainName",
|
|
270
|
+
placeholder: "domainName",
|
|
271
|
+
validate: validateDomainName,
|
|
272
|
+
},
|
|
273
|
+
{
|
|
274
|
+
route: "/tenantRelationships/findTenantInformationByTenantId(tenantId='{tenantId}')",
|
|
275
|
+
functionName: "findTenantInformationByTenantId",
|
|
276
|
+
param: "tenantId",
|
|
277
|
+
placeholder: "tenantId",
|
|
278
|
+
validate: validateTenantId,
|
|
279
|
+
},
|
|
280
|
+
{
|
|
281
|
+
route: "/directory/subscriptions(commerceSubscriptionId='{commerceSubscriptionId}')",
|
|
282
|
+
functionName: "subscriptions",
|
|
283
|
+
param: "commerceSubscriptionId",
|
|
284
|
+
placeholder: "commerceSubscriptionId",
|
|
285
|
+
validate: validateCommerceSubscriptionId,
|
|
286
|
+
},
|
|
287
|
+
];
|
|
288
|
+
export function functionBindingFor(route) {
|
|
289
|
+
return FUNCTION_ARGUMENT_BINDINGS.find(entry => entry.route === route);
|
|
290
|
+
}
|
|
291
|
+
function functionTemplate(binding) {
|
|
292
|
+
return `${binding.functionName}(${binding.param}='{${binding.placeholder}}')`;
|
|
293
|
+
}
|
|
294
|
+
function encodeFunctionArgument(value) {
|
|
295
|
+
const literal = `'${value.replaceAll("'", "''")}'`;
|
|
296
|
+
return encodeURIComponent(literal).replace(/[!'()*]/g, char => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
297
|
+
}
|
|
298
|
+
export function encodeGraphPathSegment(value) {
|
|
299
|
+
if (typeof value !== "string" || !value.length || value === "." || value === ".." || UNSAFE_PARAM.test(value) || CONTROL.test(value)) {
|
|
300
|
+
throw new AxiError("Invalid path parameter", "VALIDATION_ERROR", [
|
|
301
|
+
"Bind one resource identifier per placeholder; encoded separators and traversal are rejected",
|
|
302
|
+
]);
|
|
303
|
+
}
|
|
304
|
+
if (value.startsWith("$")) {
|
|
305
|
+
throw new AxiError("OData reserved segments cannot be resource identifiers", "VALIDATION_ERROR", [
|
|
306
|
+
"Bind a resource identifier that does not start with $; $count, $value and $ref are reserved route segments",
|
|
307
|
+
]);
|
|
308
|
+
}
|
|
309
|
+
return encodeURIComponent(value);
|
|
310
|
+
}
|
|
311
|
+
function bindFunctionSegment(route, segment, params, used) {
|
|
312
|
+
const binding = functionBindingFor(route);
|
|
313
|
+
if (!binding || segment !== functionTemplate(binding))
|
|
314
|
+
return null;
|
|
315
|
+
if (!Object.hasOwn(params, binding.placeholder)) {
|
|
316
|
+
throw new AxiError(`Missing path parameter {${binding.placeholder}} for ${route}`, "VALIDATION_ERROR", [
|
|
317
|
+
"Bind one resource identifier per placeholder from the catalogued route",
|
|
318
|
+
]);
|
|
319
|
+
}
|
|
320
|
+
used.add(binding.placeholder);
|
|
321
|
+
const value = params[binding.placeholder];
|
|
322
|
+
binding.validate(value);
|
|
323
|
+
return `${binding.functionName}(${binding.param}=${encodeFunctionArgument(value)})`;
|
|
324
|
+
}
|
|
325
|
+
function buildPath(route, params) {
|
|
326
|
+
const used = new Set();
|
|
327
|
+
const path = splitPath(route)
|
|
328
|
+
.map(segment => {
|
|
329
|
+
const name = bindingName(segment);
|
|
330
|
+
if (!name) {
|
|
331
|
+
const bound = bindFunctionSegment(route, segment, params, used);
|
|
332
|
+
if (bound !== null)
|
|
333
|
+
return bound;
|
|
334
|
+
if (/[{}]/.test(segment)) {
|
|
335
|
+
throw new AxiError(`Unsupported path template ${route}`, "VALIDATION_ERROR", [
|
|
336
|
+
"Use a catalogued route with whole-segment placeholders",
|
|
337
|
+
]);
|
|
338
|
+
}
|
|
339
|
+
return segment;
|
|
340
|
+
}
|
|
341
|
+
if (!Object.hasOwn(params, name)) {
|
|
342
|
+
throw new AxiError(`Missing path parameter {${name}} for ${route}`, "VALIDATION_ERROR", [
|
|
343
|
+
"Bind one resource identifier per placeholder from the catalogued route",
|
|
344
|
+
]);
|
|
345
|
+
}
|
|
346
|
+
used.add(name);
|
|
347
|
+
const value = params[name];
|
|
348
|
+
return encodeGraphPathSegment(value);
|
|
349
|
+
})
|
|
350
|
+
.join("/");
|
|
351
|
+
const extra = Object.keys(params).find(name => !used.has(name) && splitPath(route).every(segment => bindingName(segment) !== name));
|
|
352
|
+
if (extra)
|
|
353
|
+
throw new AxiError(`Unknown path parameter {${extra}} for ${route}`, "VALIDATION_ERROR", ["Bind only the placeholders in the catalogued route"]);
|
|
354
|
+
return path;
|
|
355
|
+
}
|
|
356
|
+
function checkQueryEntry(key, value) {
|
|
357
|
+
if (!ALLOWED_KEYS.has(key)) {
|
|
358
|
+
throw new AxiError(`Unsupported query key ${key}`, "VALIDATION_ERROR", [
|
|
359
|
+
`Supported query keys: ${ALLOWED_QUERY_KEYS.join(", ")}`,
|
|
360
|
+
]);
|
|
361
|
+
}
|
|
362
|
+
if (typeof value !== "string" || !value.length || value.length > MAX_QUERY_VALUE || /[\x00-\x1f\x7f]/.test(value)) {
|
|
363
|
+
throw new AxiError(`Invalid query value for ${key}`, "VALIDATION_ERROR", ["Keep query values short printable strings"]);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
function buildQuery(query) {
|
|
367
|
+
const pairs = Object.entries(query).map(([key, value]) => {
|
|
368
|
+
checkQueryEntry(key, value);
|
|
369
|
+
return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
|
|
370
|
+
});
|
|
371
|
+
pairs.sort();
|
|
372
|
+
return pairs.length ? `?${pairs.join("&")}` : "";
|
|
373
|
+
}
|
|
374
|
+
function denied(operation, reason) {
|
|
375
|
+
return new AxiError(`Graph continuation for ${operation.id} is denied: ${reason}`, "POLICY_DENIED", [
|
|
376
|
+
"Redirects and continuations re-authorize as the same catalogued operation before credentials are attached",
|
|
377
|
+
`Expected route ${operation.path} on https://${GRAPH_HOST}/${operation.version} with the bound resource and supported query keys`,
|
|
378
|
+
]);
|
|
379
|
+
}
|
|
380
|
+
// Re-authorize a redirect or nextLink target as the same catalogued operation:
|
|
381
|
+
// same commercial host, same version, same route template, identical bound
|
|
382
|
+
// resource values and only allowed query keys. Returns the canonical URL.
|
|
383
|
+
// CORE-02 reuses this when following @odata.nextLink pages.
|
|
384
|
+
export function authorizeUrl(operation, params, raw, base, consistencyLevel) {
|
|
385
|
+
let url;
|
|
386
|
+
try {
|
|
387
|
+
url = new URL(raw, base ?? `https://${GRAPH_HOST}/${operation.version}/`);
|
|
388
|
+
}
|
|
389
|
+
catch {
|
|
390
|
+
throw denied(operation, "the target is not a parsable URL");
|
|
391
|
+
}
|
|
392
|
+
if (url.protocol !== "https:" || url.hostname.toLowerCase() !== GRAPH_HOST || url.port || url.username || url.password) {
|
|
393
|
+
throw denied(operation, "the target leaves the commercial Graph host");
|
|
394
|
+
}
|
|
395
|
+
const prefix = `/${operation.version}/`;
|
|
396
|
+
if (!url.pathname.startsWith(prefix))
|
|
397
|
+
throw denied(operation, `the target leaves version ${operation.version}`);
|
|
398
|
+
const bindings = matchRoute(operation.path, url.pathname.slice(prefix.length));
|
|
399
|
+
if (!bindings)
|
|
400
|
+
throw denied(operation, `the target leaves the catalogued route ${operation.path}`);
|
|
401
|
+
const functionBinding = functionBindingFor(operation.path);
|
|
402
|
+
for (const [name, actual] of Object.entries(bindings)) {
|
|
403
|
+
const expected = params[name];
|
|
404
|
+
if (typeof expected !== "string") {
|
|
405
|
+
throw denied(operation, `the target changes the bound resource {${name}}`);
|
|
406
|
+
}
|
|
407
|
+
const binding = functionBinding?.placeholder === name ? functionBinding : undefined;
|
|
408
|
+
if (binding)
|
|
409
|
+
binding.validate(expected);
|
|
410
|
+
const encoded = binding ? encodeFunctionArgument(expected) : encodeGraphPathSegment(expected);
|
|
411
|
+
if (actual.toLowerCase() !== encoded.toLowerCase()) {
|
|
412
|
+
throw denied(operation, `the target changes the bound resource {${name}}`);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
const queryKeys = new Set();
|
|
416
|
+
for (const [key, value] of url.searchParams) {
|
|
417
|
+
if (queryKeys.has(key))
|
|
418
|
+
throw denied(operation, `the target repeats query ${key}`);
|
|
419
|
+
queryKeys.add(key);
|
|
420
|
+
try {
|
|
421
|
+
checkQueryEntry(key, value);
|
|
422
|
+
}
|
|
423
|
+
catch {
|
|
424
|
+
throw denied(operation, `the target carries unsupported query ${key}`);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
checkQueryContext(Object.fromEntries(url.searchParams), consistencyLevel);
|
|
428
|
+
url.hash = "";
|
|
429
|
+
return url.toString();
|
|
430
|
+
}
|
|
431
|
+
function header(headers, name) {
|
|
432
|
+
for (const [key, value] of Object.entries(headers))
|
|
433
|
+
if (key.toLowerCase() === name)
|
|
434
|
+
return value;
|
|
435
|
+
return undefined;
|
|
436
|
+
}
|
|
437
|
+
function faultBody(body) {
|
|
438
|
+
if (!body)
|
|
439
|
+
return "";
|
|
440
|
+
try {
|
|
441
|
+
const parsed = redact(JSON.parse(body));
|
|
442
|
+
const code = typeof parsed?.error?.code === "string" ? parsed.error.code : null;
|
|
443
|
+
if (!code)
|
|
444
|
+
return "";
|
|
445
|
+
const message = typeof parsed?.error?.message === "string" ? parsed.error.message.slice(0, 300) : "";
|
|
446
|
+
return `: graph ${code}${message ? ` ${message}` : ""}`;
|
|
447
|
+
}
|
|
448
|
+
catch {
|
|
449
|
+
return "";
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
function checkQueryContext(query, consistencyLevel) {
|
|
453
|
+
if (consistencyLevel !== undefined && consistencyLevel !== "eventual") {
|
|
454
|
+
throw new AxiError(`Unsupported consistency ${consistencyLevel}`, "VALIDATION_ERROR", [
|
|
455
|
+
"Advanced queries use ConsistencyLevel eventual or no header",
|
|
456
|
+
]);
|
|
457
|
+
}
|
|
458
|
+
const needsEventual = Object.hasOwn(query, "$search") || String(query["$count"] ?? "").toLowerCase() === "true";
|
|
459
|
+
if (Object.hasOwn(query, "$expand")) {
|
|
460
|
+
if (needsEventual) {
|
|
461
|
+
throw new AxiError("Advanced queries cannot use $expand", "VALIDATION_ERROR", ["Use $expand separately from $search and $count=true"]);
|
|
462
|
+
}
|
|
463
|
+
for (const relationship of query["$expand"].split(",")) {
|
|
464
|
+
const path = relationship.trim();
|
|
465
|
+
if (!/^[a-zA-Z][a-zA-Z0-9]*(\/[a-zA-Z][a-zA-Z0-9]*)*$/.test(path)) {
|
|
466
|
+
throw new AxiError("Unsupported $expand relationship", "VALIDATION_ERROR", ["Expand explicit relationship paths without nested query options"]);
|
|
467
|
+
}
|
|
468
|
+
if (sensitiveArea(path)) {
|
|
469
|
+
throw new AxiError("Sensitive relationships cannot be expanded", "POLICY_DENIED", ["Mail and file content stay disabled for this profile"]);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
if (needsEventual && consistencyLevel !== "eventual") {
|
|
474
|
+
throw new AxiError("Advanced query needs ConsistencyLevel eventual", "VALIDATION_ERROR", [
|
|
475
|
+
"Pass consistencyLevel eventual with $search or $count=true; unsupported combinations fail before credentials",
|
|
476
|
+
]);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
function checkLimit(limit) {
|
|
480
|
+
if (limit !== undefined && (!Number.isSafeInteger(limit) || limit <= 0)) {
|
|
481
|
+
throw new AxiError(`Invalid row limit ${limit}`, "VALIDATION_ERROR", ["Pass a positive safe integer limit; the remainder of a fetched page is buffered, never discarded"]);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
function checkBudget(budget) {
|
|
485
|
+
const maxRequests = budget?.maxRequests ?? DEFAULT_MAX_REQUESTS;
|
|
486
|
+
const maxBytes = budget?.maxBytes ?? DEFAULT_MAX_BYTES;
|
|
487
|
+
const deadlineMs = budget?.deadlineMs ?? DEFAULT_DEADLINE_MS;
|
|
488
|
+
for (const [name, value] of [["maxRequests", maxRequests], ["maxBytes", maxBytes], ["deadlineMs", deadlineMs]]) {
|
|
489
|
+
if (!Number.isSafeInteger(value) || value <= 0)
|
|
490
|
+
throw new AxiError(`Invalid budget ${name} ${value}`, "VALIDATION_ERROR", ["Declare positive request/byte/deadline ceilings"]);
|
|
491
|
+
}
|
|
492
|
+
return { maxRequests, maxBytes, deadlineMs };
|
|
493
|
+
}
|
|
494
|
+
// Retry-After is seconds or an HTTP date; absent or unparseable means the
|
|
495
|
+
// caller decides (bounded backoff). Dates resolve through the fake clock.
|
|
496
|
+
function parseRetryAfter(raw, now) {
|
|
497
|
+
if (!raw)
|
|
498
|
+
return undefined;
|
|
499
|
+
const text = raw.trim();
|
|
500
|
+
if (!text)
|
|
501
|
+
return undefined;
|
|
502
|
+
const seconds = Number(text);
|
|
503
|
+
if (Number.isFinite(seconds))
|
|
504
|
+
return Math.max(0, seconds) * 1000;
|
|
505
|
+
const at = Date.parse(text);
|
|
506
|
+
if (Number.isNaN(at))
|
|
507
|
+
return undefined;
|
|
508
|
+
return Math.max(0, at - now);
|
|
509
|
+
}
|
|
510
|
+
function backoffMs(attempt) {
|
|
511
|
+
return Math.min(1000 * 2 ** attempt, MAX_RETRY_AFTER_MS);
|
|
512
|
+
}
|
|
513
|
+
function digestUrl(url) {
|
|
514
|
+
return createHash("sha256").update(url).digest("hex").slice(0, 16);
|
|
515
|
+
}
|
|
516
|
+
class DeadlineExceeded extends Error {
|
|
517
|
+
}
|
|
518
|
+
async function beforeDeadline(action, clock, deadline, callerSignal) {
|
|
519
|
+
callerSignal?.throwIfAborted();
|
|
520
|
+
const remaining = deadline - clock.now();
|
|
521
|
+
if (remaining <= 0)
|
|
522
|
+
throw new DeadlineExceeded();
|
|
523
|
+
const controller = new AbortController();
|
|
524
|
+
const timer = new AbortController();
|
|
525
|
+
const signal = callerSignal ? AbortSignal.any([callerSignal, controller.signal]) : controller.signal;
|
|
526
|
+
let onAbort;
|
|
527
|
+
const aborted = new Promise((_, reject) => {
|
|
528
|
+
onAbort = () => reject(signal.reason);
|
|
529
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
530
|
+
});
|
|
531
|
+
const timeout = clock.sleep(remaining, timer.signal).then(() => {
|
|
532
|
+
controller.abort(new DeadlineExceeded());
|
|
533
|
+
throw signal.reason;
|
|
534
|
+
});
|
|
535
|
+
try {
|
|
536
|
+
const result = await Promise.race([Promise.resolve().then(() => {
|
|
537
|
+
signal.throwIfAborted();
|
|
538
|
+
return action(signal);
|
|
539
|
+
}), timeout, aborted]);
|
|
540
|
+
signal.throwIfAborted();
|
|
541
|
+
if (clock.now() >= deadline)
|
|
542
|
+
throw new DeadlineExceeded();
|
|
543
|
+
return result;
|
|
544
|
+
}
|
|
545
|
+
finally {
|
|
546
|
+
timer.abort();
|
|
547
|
+
signal.removeEventListener("abort", onAbort);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
function bodyBytes(body) {
|
|
551
|
+
return Buffer.byteLength(body, "utf8");
|
|
552
|
+
}
|
|
553
|
+
function encodeCursor(operation, state) {
|
|
554
|
+
const payload = { v: 3, op: operation.id, next: state.next ?? null, buffered: state.buffered, seen: state.seen.slice(-MAX_SEEN), query: state.query, consistencyLevel: state.consistencyLevel ?? null, context: state.context, identity: state.identity };
|
|
555
|
+
return Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
|
|
556
|
+
}
|
|
557
|
+
function decodeCursor(operation, cursor) {
|
|
558
|
+
if (Buffer.byteLength(cursor, "utf8") > MAX_CURSOR_BYTES) {
|
|
559
|
+
throw new AxiError(`Collection cursor exceeds ${MAX_CURSOR_BYTES} bytes`, "VALIDATION_ERROR", ["Use a cursor within the supported size ceiling"]);
|
|
560
|
+
}
|
|
561
|
+
let payload;
|
|
562
|
+
try {
|
|
563
|
+
payload = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8"));
|
|
564
|
+
}
|
|
565
|
+
catch {
|
|
566
|
+
throw new AxiError("Invalid collection cursor", "VALIDATION_ERROR", ["Resume with the cursor from a partial result of the same operation"]);
|
|
567
|
+
}
|
|
568
|
+
const record = payload;
|
|
569
|
+
if (!record || typeof record !== "object" || record.v !== 3 || record.op !== operation.id || !(record.next === null || typeof record.next === "string") || !Array.isArray(record.buffered) || !Array.isArray(record.seen) || !record.query || typeof record.query !== "object" || Array.isArray(record.query) || !(record.consistencyLevel === null || record.consistencyLevel === "eventual") || typeof record.context !== "string" || !/^[0-9a-f]{64}$/.test(record.context) || !(record.identity === null || (typeof record.identity === "string" && /^[0-9a-f]{64}$/.test(record.identity)))) {
|
|
570
|
+
throw new AxiError("Invalid collection cursor", "VALIDATION_ERROR", ["Resume with the cursor from a partial result of the same operation"]);
|
|
571
|
+
}
|
|
572
|
+
if (record.seen.length > MAX_SEEN || record.seen.some(entry => typeof entry !== "string" || !/^[0-9a-f]{16}$/.test(entry))) {
|
|
573
|
+
throw new AxiError("Invalid collection cursor", "VALIDATION_ERROR", ["Resume with the cursor from a partial result of the same operation"]);
|
|
574
|
+
}
|
|
575
|
+
if (record.buffered.length > 5000)
|
|
576
|
+
throw new AxiError("Invalid collection cursor", "VALIDATION_ERROR", ["The cursor buffers at most one fetched page"]);
|
|
577
|
+
if (record.identity === null && (record.buffered.length > 0 || record.seen.length > 0))
|
|
578
|
+
throw new AxiError("Invalid collection cursor", "VALIDATION_ERROR", ["Fetched rows and consumed pages require a bound credential identity"]);
|
|
579
|
+
const query = record.query;
|
|
580
|
+
const consistencyLevel = record.consistencyLevel ?? undefined;
|
|
581
|
+
buildQuery(query);
|
|
582
|
+
checkQueryContext(query, consistencyLevel);
|
|
583
|
+
return { next: record.next ?? undefined, buffered: record.buffered.map(redact), seen: [...record.seen], query, consistencyLevel, context: record.context, identity: record.identity };
|
|
584
|
+
}
|
|
585
|
+
function nextLinkOf(body) {
|
|
586
|
+
if (!body || typeof body !== "object" || Array.isArray(body))
|
|
587
|
+
return undefined;
|
|
588
|
+
const value = body["@odata.nextLink"];
|
|
589
|
+
return typeof value === "string" && value ? value : undefined;
|
|
590
|
+
}
|
|
591
|
+
function valuesOf(operation, body) {
|
|
592
|
+
if (!body || typeof body !== "object" || Array.isArray(body) || !Object.hasOwn(body, "value")) {
|
|
593
|
+
throw new AxiError(`Graph returned a malformed collection body for ${operation.id}`, "GRAPH_ERROR", ["Collections carry a value array; treat anything else as unknown, not empty"]);
|
|
594
|
+
}
|
|
595
|
+
const value = body.value;
|
|
596
|
+
if (!Array.isArray(value))
|
|
597
|
+
throw new AxiError(`Graph returned a malformed collection body for ${operation.id}`, "GRAPH_ERROR", ["Collections carry a value array; treat anything else as unknown, not empty"]);
|
|
598
|
+
return value;
|
|
599
|
+
}
|
|
600
|
+
export class GraphSession {
|
|
601
|
+
deps;
|
|
602
|
+
constructor(deps) {
|
|
603
|
+
this.deps = deps;
|
|
604
|
+
}
|
|
605
|
+
cursorQuery(operation, cursor) {
|
|
606
|
+
return decodeCursor(operation, cursor).query;
|
|
607
|
+
}
|
|
608
|
+
async execute(args) {
|
|
609
|
+
checkOperation(args.operation);
|
|
610
|
+
// The inventory is authoritative: only the id is trusted from the caller,
|
|
611
|
+
// so a fabricated disposition can never widen access.
|
|
612
|
+
const operation = findOperation(args.operation.id);
|
|
613
|
+
if (!operation) {
|
|
614
|
+
throw new AxiError(`Unknown catalogued Graph operation ${args.operation.id}`, "VALIDATION_ERROR", [
|
|
615
|
+
"Use a route from inventory/operations.json; the session never builds uncatalogued URLs",
|
|
616
|
+
]);
|
|
617
|
+
}
|
|
618
|
+
const profile = validateProfile(args.profile);
|
|
619
|
+
this.authorizePolicy(profile, operation);
|
|
620
|
+
const query = args.query ?? {};
|
|
621
|
+
checkQueryContext(query, args.consistencyLevel);
|
|
622
|
+
const params = args.params ?? {};
|
|
623
|
+
const url = `https://${GRAPH_HOST}/${operation.version}/${buildPath(operation.path, params)}${buildQuery(query)}`;
|
|
624
|
+
const clock = args.clock ?? systemClock;
|
|
625
|
+
const signal = args.signal;
|
|
626
|
+
const deadline = clock.now() + DEFAULT_DEADLINE_MS;
|
|
627
|
+
let token;
|
|
628
|
+
try {
|
|
629
|
+
token = await beforeDeadline(async () => profile.mode === "delegated"
|
|
630
|
+
? (await this.deps.delegated.credential(validateDelegatedProfile(profile), this.readScopes(args.scopes))).token
|
|
631
|
+
: (await this.deps.application.credential(validateApplicationProfile(profile), this.applicationScopes(args.scopes))).token, clock, deadline, signal);
|
|
632
|
+
}
|
|
633
|
+
catch (error) {
|
|
634
|
+
signal?.throwIfAborted();
|
|
635
|
+
if (error instanceof DeadlineExceeded)
|
|
636
|
+
throw new AxiError(`Graph deadline for ${operation.id} is exceeded`, "GRAPH_ERROR", ["Narrow the query before retrying"]);
|
|
637
|
+
throw error;
|
|
638
|
+
}
|
|
639
|
+
return this.send(operation, params, url, token, { signal, clock, deadline, consistencyLevel: args.consistencyLevel, scalar: args.scalar });
|
|
640
|
+
}
|
|
641
|
+
// CORE-02: follow @odata.nextLink continuations through the same
|
|
642
|
+
// operation re-authorization and shared success redaction. Row caps never
|
|
643
|
+
// discard fetched rows: overflow stays buffered in the opaque cursor.
|
|
644
|
+
// Request/byte/deadline ceilings and continuation cycles end as truthful
|
|
645
|
+
// partial results (complete:false), never as complete. Throttle waits and
|
|
646
|
+
// deadlines resolve through the fake clock; signal aborts between
|
|
647
|
+
// requests and while waiting.
|
|
648
|
+
async collect(args) {
|
|
649
|
+
checkOperation(args.operation);
|
|
650
|
+
const operation = findOperation(args.operation.id);
|
|
651
|
+
if (!operation) {
|
|
652
|
+
throw new AxiError(`Unknown catalogued Graph operation ${args.operation.id}`, "VALIDATION_ERROR", [
|
|
653
|
+
"Use a route from inventory/operations.json; the session never builds uncatalogued URLs",
|
|
654
|
+
]);
|
|
655
|
+
}
|
|
656
|
+
const profile = validateProfile(args.profile);
|
|
657
|
+
this.authorizePolicy(profile, operation);
|
|
658
|
+
checkLimit(args.limit);
|
|
659
|
+
const budget = checkBudget(args.budget);
|
|
660
|
+
if (args.cursor !== undefined && (typeof args.cursor !== "string" || !args.cursor)) {
|
|
661
|
+
throw new AxiError("Invalid collection cursor", "VALIDATION_ERROR", ["Resume with the cursor from a partial result of the same operation"]);
|
|
662
|
+
}
|
|
663
|
+
const resumed = args.cursor === undefined ? undefined : decodeCursor(operation, args.cursor);
|
|
664
|
+
const query = { ...resumed?.query, ...args.query };
|
|
665
|
+
const consistencyLevel = args.consistencyLevel ?? resumed?.consistencyLevel;
|
|
666
|
+
checkQueryContext(query, consistencyLevel);
|
|
667
|
+
if (resumed && ((args.query !== undefined && buildQuery(query) !== buildQuery(resumed.query)) || (args.consistencyLevel !== undefined && args.consistencyLevel !== resumed.consistencyLevel))) {
|
|
668
|
+
throw new AxiError("Resume arguments conflict with collection cursor context", "VALIDATION_ERROR", ["Resume with the cursor's original query and consistency level, or omit those arguments"]);
|
|
669
|
+
}
|
|
670
|
+
const params = args.params ?? {};
|
|
671
|
+
const clock = args.clock ?? systemClock;
|
|
672
|
+
const signal = args.signal;
|
|
673
|
+
const path = buildPath(operation.path, params);
|
|
674
|
+
const context = createHash("sha256").update(JSON.stringify([
|
|
675
|
+
path, profile.mode, profile.tenantId.toLowerCase(), profile.clientId.toLowerCase(), profile.cloud,
|
|
676
|
+
profile.credentialRef, [...new Set(args.scopes ?? [])].sort(),
|
|
677
|
+
])).digest("hex");
|
|
678
|
+
if (resumed && resumed.context !== context)
|
|
679
|
+
throw new AxiError("Collection cursor resource or authentication context does not match", "VALIDATION_ERROR", ["Resume under the original resource bindings, profile identity, credential reference and scopes"]);
|
|
680
|
+
const startUrl = `https://${GRAPH_HOST}/${operation.version}/${path}${buildQuery(query)}`;
|
|
681
|
+
const resumeUrl = resumed?.next === undefined ? undefined : authorizeUrl(operation, params, resumed.next, undefined, consistencyLevel);
|
|
682
|
+
const deadline = clock.now() + budget.deadlineMs;
|
|
683
|
+
let pending = resumed ? [...resumed.buffered] : [];
|
|
684
|
+
let current = resumed ? resumeUrl : startUrl;
|
|
685
|
+
const seenList = resumed ? [...resumed.seen] : [];
|
|
686
|
+
const seen = new Set(seenList);
|
|
687
|
+
const remember = (url) => {
|
|
688
|
+
const key = digestUrl(url);
|
|
689
|
+
if (seen.has(key))
|
|
690
|
+
return;
|
|
691
|
+
seen.add(key);
|
|
692
|
+
seenList.push(key);
|
|
693
|
+
if (seenList.length > MAX_SEEN) {
|
|
694
|
+
for (const dropped of seenList.splice(0, seenList.length - MAX_SEEN))
|
|
695
|
+
seen.delete(dropped);
|
|
696
|
+
}
|
|
697
|
+
};
|
|
698
|
+
const results = [];
|
|
699
|
+
let requests = 0;
|
|
700
|
+
let bytes = 0;
|
|
701
|
+
let identity = resumed?.identity ?? null;
|
|
702
|
+
const partial = (reason, next, buffered) => ({
|
|
703
|
+
value: results,
|
|
704
|
+
query,
|
|
705
|
+
complete: false,
|
|
706
|
+
reason,
|
|
707
|
+
cursor: encodeCursor(operation, { next, buffered, seen: [...seenList], query, consistencyLevel, context, identity }),
|
|
708
|
+
requests,
|
|
709
|
+
bytes,
|
|
710
|
+
});
|
|
711
|
+
let token;
|
|
712
|
+
try {
|
|
713
|
+
const credential = await beforeDeadline(async () => profile.mode === "delegated"
|
|
714
|
+
? this.deps.delegated.credential(validateDelegatedProfile(profile), this.readScopes(args.scopes))
|
|
715
|
+
: this.deps.application.credential(validateApplicationProfile(profile), this.applicationScopes(args.scopes)), clock, deadline, signal);
|
|
716
|
+
const acquiredIdentity = createHash("sha256").update(JSON.stringify([
|
|
717
|
+
profile.mode, credential.tenantId.toLowerCase(), credential.clientId.toLowerCase(),
|
|
718
|
+
profile.mode === "delegated" && "accountId" in credential ? credential.accountId : null,
|
|
719
|
+
])).digest("hex");
|
|
720
|
+
if (identity !== null && identity !== acquiredIdentity)
|
|
721
|
+
throw new AxiError("Collection cursor credential identity does not match", "VALIDATION_ERROR", ["Resume with the original delegated account or application identity"]);
|
|
722
|
+
identity = acquiredIdentity;
|
|
723
|
+
token = credential.token;
|
|
724
|
+
}
|
|
725
|
+
catch (error) {
|
|
726
|
+
signal?.throwIfAborted();
|
|
727
|
+
if (error instanceof DeadlineExceeded)
|
|
728
|
+
return partial("deadline exceeded", current, pending);
|
|
729
|
+
throw error;
|
|
730
|
+
}
|
|
731
|
+
const headersFor = () => ({
|
|
732
|
+
Authorization: `Bearer ${token}`,
|
|
733
|
+
Accept: "application/json",
|
|
734
|
+
"client-request-id": randomUUID(),
|
|
735
|
+
...(consistencyLevel === "eventual" ? { ConsistencyLevel: "eventual" } : {}),
|
|
736
|
+
});
|
|
737
|
+
for (;;) {
|
|
738
|
+
signal?.throwIfAborted();
|
|
739
|
+
if (clock.now() >= deadline)
|
|
740
|
+
return partial("deadline exceeded", current, pending);
|
|
741
|
+
while (pending.length > 0 && (args.limit === undefined || results.length < args.limit))
|
|
742
|
+
results.push(pending.shift());
|
|
743
|
+
if (clock.now() >= deadline)
|
|
744
|
+
return partial("deadline exceeded", current, pending);
|
|
745
|
+
if (args.limit !== undefined && results.length >= args.limit) {
|
|
746
|
+
if (pending.length === 0 && !current)
|
|
747
|
+
return { value: results, query, complete: true, requests, bytes };
|
|
748
|
+
return partial("row limit reached; buffered remainder is preserved in the cursor", current, pending);
|
|
749
|
+
}
|
|
750
|
+
if (!current)
|
|
751
|
+
return { value: results, query, complete: true, requests, bytes };
|
|
752
|
+
if (requests >= budget.maxRequests)
|
|
753
|
+
return partial(`request budget exhausted after ${requests} requests`, current, pending);
|
|
754
|
+
const cycleKey = digestUrl(current);
|
|
755
|
+
if (seen.has(cycleKey))
|
|
756
|
+
return partial("continuation cycle detected; result is partial, never complete", current, pending);
|
|
757
|
+
let fetchUrl = current;
|
|
758
|
+
const redirects = new Set([digestUrl(fetchUrl)]);
|
|
759
|
+
let hops = 0;
|
|
760
|
+
let throttleAttempt = 0;
|
|
761
|
+
for (;;) {
|
|
762
|
+
signal?.throwIfAborted();
|
|
763
|
+
if (requests >= budget.maxRequests)
|
|
764
|
+
return partial(`request budget exhausted after ${requests} requests`, fetchUrl, pending);
|
|
765
|
+
if (clock.now() >= deadline)
|
|
766
|
+
return partial("deadline exceeded", fetchUrl, pending);
|
|
767
|
+
let response;
|
|
768
|
+
try {
|
|
769
|
+
requests += 1;
|
|
770
|
+
const requestUrl = new URL(fetchUrl);
|
|
771
|
+
if (operation.path === "/users/{user-id}/authentication/methods")
|
|
772
|
+
requestUrl.searchParams.delete("$select");
|
|
773
|
+
response = await beforeDeadline(signal => this.deps.transport({ method: "GET", url: requestUrl.toString(), headers: headersFor(), signal }), clock, deadline, signal);
|
|
774
|
+
}
|
|
775
|
+
catch (error) {
|
|
776
|
+
signal?.throwIfAborted();
|
|
777
|
+
if (error instanceof DeadlineExceeded)
|
|
778
|
+
return partial("deadline exceeded", fetchUrl, pending);
|
|
779
|
+
throw new AxiError(`Graph request for ${operation.id} failed before a response was received`, "GRAPH_ERROR", [
|
|
780
|
+
`Check network access to https://${GRAPH_HOST}`,
|
|
781
|
+
"Transport failures carry no Graph diagnosis; do not retry blindly",
|
|
782
|
+
]);
|
|
783
|
+
}
|
|
784
|
+
bytes += response.receivedBodyBytes ?? bodyBytes(response.body ?? "");
|
|
785
|
+
const status = response.status;
|
|
786
|
+
if (status === 301 || status === 302 || status === 303 || status === 307 || status === 308) {
|
|
787
|
+
const location = header(response.headers, "location");
|
|
788
|
+
if (!location)
|
|
789
|
+
throw new AxiError(`Graph redirect for ${operation.id} is missing its target`, "GRAPH_ERROR", ["A redirect without a target cannot be re-authorized"]);
|
|
790
|
+
if (hops >= MAX_REDIRECTS)
|
|
791
|
+
throw denied(operation, `the redirect exceeds ${MAX_REDIRECTS} hops`);
|
|
792
|
+
const target = authorizeUrl(operation, params, location, fetchUrl, consistencyLevel);
|
|
793
|
+
if (seen.has(digestUrl(target)) || redirects.has(digestUrl(target)))
|
|
794
|
+
return partial("continuation cycle detected; result is partial, never complete", target, pending);
|
|
795
|
+
redirects.add(digestUrl(target));
|
|
796
|
+
fetchUrl = target;
|
|
797
|
+
hops += 1;
|
|
798
|
+
if (bytes > budget.maxBytes)
|
|
799
|
+
return partial(`byte budget exceeded after ${bytes} bytes`, fetchUrl, pending);
|
|
800
|
+
continue;
|
|
801
|
+
}
|
|
802
|
+
if (status === 429 || status === 503) {
|
|
803
|
+
if (bytes > budget.maxBytes)
|
|
804
|
+
return partial(`byte budget exceeded after ${bytes} bytes`, fetchUrl, pending);
|
|
805
|
+
const raw = header(response.headers, "retry-after");
|
|
806
|
+
let wait = parseRetryAfter(raw, clock.now());
|
|
807
|
+
if (wait === undefined)
|
|
808
|
+
wait = backoffMs(throttleAttempt);
|
|
809
|
+
throttleAttempt += 1;
|
|
810
|
+
const detail = raw ? `; retry after ${raw}` : "";
|
|
811
|
+
if (requests >= budget.maxRequests)
|
|
812
|
+
return partial(`request budget exhausted after ${requests} requests`, fetchUrl, pending);
|
|
813
|
+
if (clock.now() + wait > deadline)
|
|
814
|
+
return partial(`deadline exceeded; throttled (${status})${detail} would overrun the budget`, fetchUrl, pending);
|
|
815
|
+
if (wait > MAX_RETRY_AFTER_MS)
|
|
816
|
+
return partial(`throttled (${status})${detail}; budget preserves ${results.length + pending.length} rows`, fetchUrl, pending);
|
|
817
|
+
await clock.sleep(wait, signal);
|
|
818
|
+
continue;
|
|
819
|
+
}
|
|
820
|
+
if (status < 200 || status >= 300)
|
|
821
|
+
throw this.translateError(operation, response);
|
|
822
|
+
if (status === 204 || status === 205) {
|
|
823
|
+
remember(current);
|
|
824
|
+
remember(fetchUrl);
|
|
825
|
+
current = undefined;
|
|
826
|
+
if (bytes > budget.maxBytes)
|
|
827
|
+
return partial(`byte budget exceeded after ${bytes} bytes`, current, pending);
|
|
828
|
+
break;
|
|
829
|
+
}
|
|
830
|
+
if (!response.body)
|
|
831
|
+
throw new AxiError(`Graph returned an empty success body for ${operation.id}`, "GRAPH_ERROR", ["Empty reads are malformed; treat the result as unknown, not empty"]);
|
|
832
|
+
let parsed;
|
|
833
|
+
try {
|
|
834
|
+
parsed = redact(JSON.parse(response.body));
|
|
835
|
+
}
|
|
836
|
+
catch {
|
|
837
|
+
throw new AxiError(`Graph returned a non-JSON success body for ${operation.id}`, "GRAPH_ERROR", ["Successful reads are JSON; anything else is malformed"]);
|
|
838
|
+
}
|
|
839
|
+
const rows = valuesOf(operation, parsed);
|
|
840
|
+
const rawNext = nextLinkOf(parsed);
|
|
841
|
+
const nextUrl = rawNext ? authorizeUrl(operation, params, rawNext, fetchUrl, consistencyLevel) : undefined;
|
|
842
|
+
remember(current);
|
|
843
|
+
remember(fetchUrl);
|
|
844
|
+
current = nextUrl;
|
|
845
|
+
pending = [...pending, ...rows];
|
|
846
|
+
if (bytes > budget.maxBytes)
|
|
847
|
+
return partial(`byte budget exceeded after ${bytes} bytes`, current, pending);
|
|
848
|
+
break;
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
authorizePolicy(profile, operation) {
|
|
853
|
+
if (operation.method !== "GET") {
|
|
854
|
+
throw new AxiError(`Graph session executes reads; ${operation.id} is not a read`, "VALIDATION_ERROR", [
|
|
855
|
+
"Named mutations ship with their own coordinator slice; the session never sends them",
|
|
856
|
+
]);
|
|
857
|
+
}
|
|
858
|
+
// Profile-scoped gates run before the catalogued verdict, so a route that
|
|
859
|
+
// is both sensitive and excluded reports the sensitive denial.
|
|
860
|
+
// Every current owning slice belongs to the entra pack; a profile without
|
|
861
|
+
// it cannot execute. New packs extend this gate with their own review.
|
|
862
|
+
if (!profile.enabledPacks.includes("entra")) {
|
|
863
|
+
throw new AxiError("Pack entra is not enabled for this profile", "POLICY_DENIED", ["mg-axi profile show --profile <name>"]);
|
|
864
|
+
}
|
|
865
|
+
if (operation.version === "beta" && !profile.preview) {
|
|
866
|
+
throw new AxiError(`Beta Graph reads require a preview-enabled profile (${operation.id})`, "POLICY_DENIED", [
|
|
867
|
+
"Enable preview explicitly for beta reads; v1.0 stays the default with no fallback",
|
|
868
|
+
]);
|
|
869
|
+
}
|
|
870
|
+
const route = operation.path.toLowerCase();
|
|
871
|
+
if (profile.mode === "application" && (route === "/me" || route.startsWith("/me/"))) {
|
|
872
|
+
throw new AxiError("Application profiles cannot use /me; bind an explicit /users/{id} route", "POLICY_DENIED", [
|
|
873
|
+
"App-only calls address the resource directly; /me needs a signed-in user",
|
|
874
|
+
]);
|
|
875
|
+
}
|
|
876
|
+
const area = sensitiveArea(operation.path);
|
|
877
|
+
if (area && !profile.sensitiveAreas.includes(area)) {
|
|
878
|
+
throw new AxiError(`Sensitive area ${area} is not enabled for this profile (${operation.id})`, "POLICY_DENIED", [
|
|
879
|
+
"Mail and file content stay disabled unless explicitly enabled for the profile",
|
|
880
|
+
]);
|
|
881
|
+
}
|
|
882
|
+
if (!EXECUTABLE_DISPOSITIONS.has(operation.disposition)) {
|
|
883
|
+
throw new AxiError(`Graph operation ${operation.id} is ${operation.disposition}; it cannot be executed`, "POLICY_DENIED", [
|
|
884
|
+
"Blocked, deprecated and unavailable operations stay denied even when reached by redirect",
|
|
885
|
+
]);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
readScopes(scopes) {
|
|
889
|
+
if (!scopes?.length || scopes.some(scope => !READ_SCOPES.has(scope))) {
|
|
890
|
+
throw new AxiError("Graph reads require supported read scopes", "VALIDATION_ERROR", [
|
|
891
|
+
`Supported read scopes: ${[...READ_SCOPES].join(", ")}`,
|
|
892
|
+
]);
|
|
893
|
+
}
|
|
894
|
+
return scopes;
|
|
895
|
+
}
|
|
896
|
+
applicationScopes(scopes) {
|
|
897
|
+
if (scopes !== undefined) {
|
|
898
|
+
throw new AxiError("Application profiles use the configured Graph .default audience; delegated scopes are unavailable", "VALIDATION_ERROR", [
|
|
899
|
+
"mg-axi profile show --profile <name>",
|
|
900
|
+
]);
|
|
901
|
+
}
|
|
902
|
+
return undefined;
|
|
903
|
+
}
|
|
904
|
+
async send(operation, params, url, token, opts) {
|
|
905
|
+
const clock = opts.clock;
|
|
906
|
+
const signal = opts.signal;
|
|
907
|
+
const deadline = opts.deadline;
|
|
908
|
+
let requests = 0;
|
|
909
|
+
let current = url;
|
|
910
|
+
let hops = 0;
|
|
911
|
+
let throttleAttempt = 0;
|
|
912
|
+
const visited = new Set([current]);
|
|
913
|
+
const headersFor = () => ({
|
|
914
|
+
Authorization: `Bearer ${token}`,
|
|
915
|
+
Accept: "application/json",
|
|
916
|
+
"client-request-id": randomUUID(),
|
|
917
|
+
...(opts.consistencyLevel === "eventual" ? { ConsistencyLevel: "eventual" } : {}),
|
|
918
|
+
// The branding GET documents Accept-Language as required; 0 selects
|
|
919
|
+
// the default branding object. Locale variants come from the
|
|
920
|
+
// localizations collection instead of this header.
|
|
921
|
+
...(operation.path === "/organization/{organization-id}/branding" ? { "Accept-Language": "0" } : {}),
|
|
922
|
+
});
|
|
923
|
+
for (;;) {
|
|
924
|
+
signal?.throwIfAborted();
|
|
925
|
+
if (requests >= DEFAULT_MAX_REQUESTS)
|
|
926
|
+
throw new AxiError(`Graph request budget for ${operation.id} is exhausted after ${requests} requests`, "GRAPH_ERROR", ["Narrow the query before retrying"]);
|
|
927
|
+
if (clock.now() >= deadline)
|
|
928
|
+
throw new AxiError(`Graph deadline for ${operation.id} is exceeded`, "GRAPH_ERROR", ["Narrow the query before retrying"]);
|
|
929
|
+
let response;
|
|
930
|
+
try {
|
|
931
|
+
response = await beforeDeadline(signal => this.deps.transport({ method: "GET", url: current, headers: headersFor(), signal }), clock, deadline, signal);
|
|
932
|
+
}
|
|
933
|
+
catch (error) {
|
|
934
|
+
signal?.throwIfAborted();
|
|
935
|
+
if (error instanceof DeadlineExceeded)
|
|
936
|
+
throw new AxiError(`Graph deadline for ${operation.id} is exceeded`, "GRAPH_ERROR", ["Narrow the query before retrying"]);
|
|
937
|
+
throw new AxiError(`Graph request for ${operation.id} failed before a response was received`, "GRAPH_ERROR", [
|
|
938
|
+
`Check network access to https://${GRAPH_HOST}`,
|
|
939
|
+
"Transport failures carry no Graph diagnosis; do not retry blindly",
|
|
940
|
+
]);
|
|
941
|
+
}
|
|
942
|
+
requests += 1;
|
|
943
|
+
if (response.status === 301 || response.status === 302 || response.status === 303 || response.status === 307 || response.status === 308) {
|
|
944
|
+
const location = header(response.headers, "location");
|
|
945
|
+
if (!location)
|
|
946
|
+
throw new AxiError(`Graph redirect for ${operation.id} is missing its target`, "GRAPH_ERROR", ["A redirect without a target cannot be re-authorized"]);
|
|
947
|
+
if (hops >= MAX_REDIRECTS)
|
|
948
|
+
throw denied(operation, `the redirect exceeds ${MAX_REDIRECTS} hops`);
|
|
949
|
+
const target = authorizeUrl(operation, params, location, current, opts.consistencyLevel);
|
|
950
|
+
if (visited.has(target))
|
|
951
|
+
throw denied(operation, "the redirect loops");
|
|
952
|
+
visited.add(target);
|
|
953
|
+
current = target;
|
|
954
|
+
hops += 1;
|
|
955
|
+
throttleAttempt = 0;
|
|
956
|
+
continue;
|
|
957
|
+
}
|
|
958
|
+
if (response.status === 429 || response.status === 503) {
|
|
959
|
+
const raw = header(response.headers, "retry-after");
|
|
960
|
+
let wait = parseRetryAfter(raw, clock.now());
|
|
961
|
+
if (wait === undefined)
|
|
962
|
+
wait = backoffMs(throttleAttempt);
|
|
963
|
+
throttleAttempt += 1;
|
|
964
|
+
if (wait > MAX_RETRY_AFTER_MS || requests >= DEFAULT_MAX_REQUESTS || clock.now() + wait > deadline)
|
|
965
|
+
throw this.translateError(operation, response);
|
|
966
|
+
await clock.sleep(wait, signal);
|
|
967
|
+
continue;
|
|
968
|
+
}
|
|
969
|
+
const result = opts.scalar === true ? this.translateScalar(operation, response) : this.translate(operation, response);
|
|
970
|
+
signal?.throwIfAborted();
|
|
971
|
+
if (clock.now() >= deadline)
|
|
972
|
+
throw new AxiError(`Graph deadline for ${operation.id} is exceeded`, "GRAPH_ERROR", ["Narrow the query before retrying"]);
|
|
973
|
+
return result;
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
translate(operation, response) {
|
|
977
|
+
const status = response.status;
|
|
978
|
+
if (status === 204 || status === 205)
|
|
979
|
+
return null;
|
|
980
|
+
if (status >= 200 && status < 300) {
|
|
981
|
+
if (!response.body)
|
|
982
|
+
throw new AxiError(`Graph returned an empty success body for ${operation.id}`, "GRAPH_ERROR", ["Empty reads are malformed; treat the result as unknown, not empty"]);
|
|
983
|
+
try {
|
|
984
|
+
return redact(JSON.parse(response.body));
|
|
985
|
+
}
|
|
986
|
+
catch {
|
|
987
|
+
throw new AxiError(`Graph returned a non-JSON success body for ${operation.id}`, "GRAPH_ERROR", ["Successful reads are JSON; anything else is malformed"]);
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
throw this.translateError(operation, response);
|
|
991
|
+
}
|
|
992
|
+
// $count-style scalar reads carry a plain-text number, not JSON. Anything
|
|
993
|
+
// else is malformed; errors still translate through the shared mapping.
|
|
994
|
+
translateScalar(operation, response) {
|
|
995
|
+
const status = response.status;
|
|
996
|
+
if (status >= 200 && status < 300) {
|
|
997
|
+
const text = (response.body ?? "").trim();
|
|
998
|
+
if (/^\d+$/.test(text))
|
|
999
|
+
return Number(text);
|
|
1000
|
+
throw new AxiError(`Graph returned a non-numeric success body for ${operation.id}`, "GRAPH_ERROR", ["Scalar count reads carry a plain number; treat anything else as unknown, not empty"]);
|
|
1001
|
+
}
|
|
1002
|
+
throw this.translateError(operation, response);
|
|
1003
|
+
}
|
|
1004
|
+
translateError(operation, response) {
|
|
1005
|
+
const status = response.status;
|
|
1006
|
+
const fault = faultBody(response.body);
|
|
1007
|
+
if (status === 401) {
|
|
1008
|
+
return new AxiError(`Graph rejected the credential for ${operation.id} (401)${fault}`, "AUTH_REQUIRED", [
|
|
1009
|
+
"mg-axi login --profile <name> --scopes <comma-separated-Graph-scopes>",
|
|
1010
|
+
"For application profiles, ask an administrator to grant application consent for the Graph .default audience",
|
|
1011
|
+
]);
|
|
1012
|
+
}
|
|
1013
|
+
if (status === 403) {
|
|
1014
|
+
return new AxiError(`Graph denied ${operation.id} (403)${fault}: the grant, role, licence or policy prerequisite is missing and Graph does not say which`, "GRAPH_ERROR", [
|
|
1015
|
+
"Check the catalogue permission and role guidance for this operation",
|
|
1016
|
+
"Confirm tenant licensing for the generating feature",
|
|
1017
|
+
"A 403 never proves which prerequisite is missing; do not retry blindly",
|
|
1018
|
+
]);
|
|
1019
|
+
}
|
|
1020
|
+
if (status === 404) {
|
|
1021
|
+
return new AxiError(`Graph reports ${operation.id} as not found or inaccessible (404)${fault}; Graph does not distinguish missing from hidden`, "GRAPH_ERROR", [
|
|
1022
|
+
"Verify the bound identifier; absence is not proof of nonexistence",
|
|
1023
|
+
]);
|
|
1024
|
+
}
|
|
1025
|
+
if (status === 429 || status === 503) {
|
|
1026
|
+
const retryAfter = header(response.headers, "retry-after");
|
|
1027
|
+
return new AxiError(`Graph throttled ${operation.id} (${status})${retryAfter ? `; retry after ${retryAfter}` : ""}${fault}`, "GRAPH_ERROR", [
|
|
1028
|
+
"Bounded safe-read retries honor Retry-After within request and deadline budgets",
|
|
1029
|
+
"Narrow the query when throttling persists",
|
|
1030
|
+
]);
|
|
1031
|
+
}
|
|
1032
|
+
return new AxiError(`Graph failed ${operation.id} (${status})${fault}`, "GRAPH_ERROR", ["Server failures may be transient; retry safe reads within a bounded budget"]);
|
|
1033
|
+
}
|
|
1034
|
+
}
|