@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.
Files changed (57) hide show
  1. package/README.md +1138 -0
  2. package/dist/api.js +953 -0
  3. package/dist/app-auth.js +47 -0
  4. package/dist/auth.js +59 -0
  5. package/dist/bin/mg-axi.js +7 -0
  6. package/dist/catalogue.js +2191 -0
  7. package/dist/cli.js +1314 -0
  8. package/dist/docs.js +559 -0
  9. package/dist/doctor.js +75 -0
  10. package/dist/entra-access-reviews.js +425 -0
  11. package/dist/entra-apps.js +374 -0
  12. package/dist/entra-audit-logs.js +381 -0
  13. package/dist/entra-auth-methods.js +296 -0
  14. package/dist/entra-ca-policy-update.js +353 -0
  15. package/dist/entra-certificate-auth.js +384 -0
  16. package/dist/entra-conditional-access.js +317 -0
  17. package/dist/entra-contacts.js +827 -0
  18. package/dist/entra-contracts.js +252 -0
  19. package/dist/entra-custom-security-attributes.js +464 -0
  20. package/dist/entra-data-policy-operations.js +274 -0
  21. package/dist/entra-delegated-admin.js +468 -0
  22. package/dist/entra-deleted-items.js +461 -0
  23. package/dist/entra-directory-objects.js +274 -0
  24. package/dist/entra-directory.js +411 -0
  25. package/dist/entra-domains.js +399 -0
  26. package/dist/entra-entitlement-management.js +645 -0
  27. package/dist/entra-federation-configurations.js +290 -0
  28. package/dist/entra-grants.js +272 -0
  29. package/dist/entra-group-lifecycle.js +349 -0
  30. package/dist/entra-group-member-add.js +278 -0
  31. package/dist/entra-groups.js +336 -0
  32. package/dist/entra-identity-providers.js +297 -0
  33. package/dist/entra-lifecycle-workflows.js +673 -0
  34. package/dist/entra-multi-tenant-organization.js +309 -0
  35. package/dist/entra-on-premises-synchronization.js +231 -0
  36. package/dist/entra-organization.js +398 -0
  37. package/dist/entra-risk-dismiss.js +191 -0
  38. package/dist/entra-risk.js +404 -0
  39. package/dist/entra-roles.js +310 -0
  40. package/dist/entra-subscriptions.js +289 -0
  41. package/dist/entra-tenant-information.js +152 -0
  42. package/dist/entra-terms-of-use.js +380 -0
  43. package/dist/entra-user-revoke-sessions.js +170 -0
  44. package/dist/entra-user-update.js +170 -0
  45. package/dist/entra-users.js +211 -0
  46. package/dist/graph-session.js +1034 -0
  47. package/dist/msal-app-provider.js +42 -0
  48. package/dist/msal-provider.js +82 -0
  49. package/dist/mutations.js +529 -0
  50. package/dist/profiles.js +139 -0
  51. package/dist/setup.js +116 -0
  52. package/dist/version.js +2 -0
  53. package/docs/coverage.md +1028 -0
  54. package/inventory/operations.json +4 -0
  55. package/inventory/schema.json +71 -0
  56. package/package.json +32 -0
  57. package/skills/mg-axi/SKILL.md +293 -0
@@ -0,0 +1,827 @@
1
+ import { AxiError } from "axi-sdk-js";
2
+ // EXT-01 contacts subfamily plus EXT-01m/EXT-01n navigation: the read mapping
3
+ // behind `mg-axi entra contact list/show/count`, the manager/directReports
4
+ // navigation reads (`show-manager`, `list/show/count-direct-reports`) and the
5
+ // memberOf/transitiveMemberOf membership reads (`list/show/count-member-of`).
6
+ // Operation construction stays beside its command; the shared session owns URLs, credentials, paging,
7
+ // retries and error translation, and the SDK owns TOON rendering. This
8
+ // module only maps flags to session calls and projects rows for compact
9
+ // output.
10
+ //
11
+ // Reviewed against the v1.0 orgcontact-list, orgcontact-get and orgContact
12
+ // resource documentation on 2026-10-05. Both reads take delegated or
13
+ // application OrgContact.Read.All, the documented least privilege in each
14
+ // mode (Directory.Read.All and Directory.ReadWrite.All are documented only
15
+ // as higher-privileged alternatives); personal Microsoft accounts are not
16
+ // supported. Delegated callers additionally need a supported Entra role:
17
+ // Directory Readers reads basic properties, and Global Reader, Directory
18
+ // Writers, Intune Administrator or User Administrator also work. No P1/P2
19
+ // prerequisite is stated for these reads. The $count scalar carries no
20
+ // operation-level documentation page and follows the same OrgContact.Read.All
21
+ // contract; the list page's $count example sends ConsistencyLevel eventual,
22
+ // so the scalar does too. Membership reads are reviewed against the v1.0
23
+ // orgcontact-list-memberof and orgcontact-list-transitivememberof operation
24
+ // documentation plus the orgContact resource reference on 2026-10-05 (see the
25
+ // EXT-01n block below): direct reads take delegated or application
26
+ // OrgContact.Read.All, the documented least privilege in each mode
27
+ // (Directory.Read.All, Directory.ReadWrite.All and Group.Read.All are
28
+ // documented only as higher-privileged alternatives), while transitive reads
29
+ // take delegated or application OrgContact.Read.All and Group.Read.All
30
+ // together (Directory.Read.All is the documented higher-privileged
31
+ // alternative); the same supported Entra roles, personal-account exclusion
32
+ // and no-P1/P2 prerequisite apply. The delta() sync, the serviceProvisioningErrors and
33
+ // onPremisesSyncBehavior reads (unavailable with no documented permission
34
+ // contract), every POST lookup action, beta and every mutation stay out. No mutation lives here.
35
+ //
36
+ // Secrecy by construction: contacts are personal data, so default rows carry
37
+ // only id, displayName, mail and companyName; identifying fields beyond that
38
+ // need an explicit --select. Top-level list and show request and project
39
+ // only flat scalar properties; $expand is not offered, so navigation objects
40
+ // can never appear there, and the nested phones/addresses collections need
41
+ // their own projection review. Navigation rows are directory objects with
42
+ // the @odata.type discriminator plus minimal fields (see the EXT-01m block
43
+ // below). Membership rows are directory objects too: the @odata.type
44
+ // discriminator plus id and displayName by default (see the EXT-01n block
45
+ // below).
46
+ // Every contact property this slice may request or display, matching the
47
+ // reviewed resource. Anything else fails before credentials.
48
+ export const KNOWN_CONTACT_FIELDS = [
49
+ "id",
50
+ "deletedDateTime",
51
+ "companyName",
52
+ "department",
53
+ "displayName",
54
+ "givenName",
55
+ "surname",
56
+ "mail",
57
+ "mailNickname",
58
+ "jobTitle",
59
+ "onPremisesLastSyncDateTime",
60
+ "onPremisesSyncEnabled",
61
+ "proxyAddresses",
62
+ ];
63
+ const KNOWN_CONTACTS = new Set(KNOWN_CONTACT_FIELDS);
64
+ // Compact rows stay minimal on purpose: contacts are personal data, so list
65
+ // and show both default to id, displayName, mail and companyName only.
66
+ const DEFAULT_LIST_SELECT = ["id", "displayName", "mail", "companyName"];
67
+ const DEFAULT_SHOW_SELECT = [...DEFAULT_LIST_SELECT];
68
+ // OrgContact.Read.All covers list, show and count in both modes; delegated
69
+ // reads default to it unless --scopes overrides it. It is new to the shared
70
+ // READ_SCOPES allowlist, and it is a read scope, so no deferral is needed
71
+ // here.
72
+ export const DEFAULT_CONTACT_SCOPES = ["https://graph.microsoft.com/OrgContact.Read.All"];
73
+ const TRUNCATE_AT = 500;
74
+ // The only operations this slice ever binds: the three catalogued v1.0
75
+ // top-level contacts GETs, the ten EXT-01m manager/directReports navigation
76
+ // GETs and the eighteen EXT-01n memberOf/transitiveMemberOf membership GETs
77
+ // (lists, $count scalars, singles and typed casts). Anything else - delta
78
+ // sync, error/sync reads, POST lookup actions, writes - is refused before
79
+ // credentials.
80
+ const READ_OPERATIONS = {
81
+ "GET:/contacts": "contact",
82
+ "GET:/contacts/{orgContact-id}": "contact",
83
+ "GET:/contacts/$count": "contact",
84
+ "GET:/contacts/{orgContact-id}/manager": "contact-navigation",
85
+ "GET:/contacts/{orgContact-id}/directReports": "contact-navigation",
86
+ "GET:/contacts/{orgContact-id}/directReports/$count": "contact-navigation",
87
+ "GET:/contacts/{orgContact-id}/directReports/graph.orgContact": "contact-navigation",
88
+ "GET:/contacts/{orgContact-id}/directReports/graph.orgContact/$count": "contact-navigation",
89
+ "GET:/contacts/{orgContact-id}/directReports/graph.user": "contact-navigation",
90
+ "GET:/contacts/{orgContact-id}/directReports/graph.user/$count": "contact-navigation",
91
+ "GET:/contacts/{orgContact-id}/directReports/{directoryObject-id}": "contact-navigation",
92
+ "GET:/contacts/{orgContact-id}/directReports/{directoryObject-id}/graph.orgContact": "contact-navigation",
93
+ "GET:/contacts/{orgContact-id}/directReports/{directoryObject-id}/graph.user": "contact-navigation",
94
+ "GET:/contacts/{orgContact-id}/memberOf": "contact-membership",
95
+ "GET:/contacts/{orgContact-id}/memberOf/$count": "contact-membership",
96
+ "GET:/contacts/{orgContact-id}/memberOf/graph.administrativeUnit": "contact-membership",
97
+ "GET:/contacts/{orgContact-id}/memberOf/graph.administrativeUnit/$count": "contact-membership",
98
+ "GET:/contacts/{orgContact-id}/memberOf/graph.group": "contact-membership",
99
+ "GET:/contacts/{orgContact-id}/memberOf/graph.group/$count": "contact-membership",
100
+ "GET:/contacts/{orgContact-id}/memberOf/{directoryObject-id}": "contact-membership",
101
+ "GET:/contacts/{orgContact-id}/memberOf/{directoryObject-id}/graph.administrativeUnit": "contact-membership",
102
+ "GET:/contacts/{orgContact-id}/memberOf/{directoryObject-id}/graph.group": "contact-membership",
103
+ "GET:/contacts/{orgContact-id}/transitiveMemberOf": "contact-membership",
104
+ "GET:/contacts/{orgContact-id}/transitiveMemberOf/$count": "contact-membership",
105
+ "GET:/contacts/{orgContact-id}/transitiveMemberOf/graph.administrativeUnit": "contact-membership",
106
+ "GET:/contacts/{orgContact-id}/transitiveMemberOf/graph.administrativeUnit/$count": "contact-membership",
107
+ "GET:/contacts/{orgContact-id}/transitiveMemberOf/graph.group": "contact-membership",
108
+ "GET:/contacts/{orgContact-id}/transitiveMemberOf/graph.group/$count": "contact-membership",
109
+ "GET:/contacts/{orgContact-id}/transitiveMemberOf/{directoryObject-id}": "contact-membership",
110
+ "GET:/contacts/{orgContact-id}/transitiveMemberOf/{directoryObject-id}/graph.administrativeUnit": "contact-membership",
111
+ "GET:/contacts/{orgContact-id}/transitiveMemberOf/{directoryObject-id}/graph.group": "contact-membership",
112
+ };
113
+ function checkReadOperation(operation, help) {
114
+ const route = `${operation.method}:${operation.path}`;
115
+ if (operation.method !== "GET" || !Object.hasOwn(READ_OPERATIONS, route)) {
116
+ throw new AxiError(`Refused non-read route ${operation.id}: contact serves only the catalogued v1.0 top-level, manager/directReports and memberOf/transitiveMemberOf membership GETs`, "VALIDATION_ERROR", [
117
+ help,
118
+ "Delta sync, error/sync reads, POST lookup actions and writes are never constructed here; beta needs its own review",
119
+ ]);
120
+ }
121
+ }
122
+ function fieldList(raw, known, knownList, flag, help) {
123
+ const parts = String(raw)
124
+ .split(",")
125
+ .map(part => part.trim())
126
+ .filter(part => part.length > 0);
127
+ if (!parts.length)
128
+ throw new AxiError(`--${flag} needs at least one property`, "VALIDATION_ERROR", [help]);
129
+ const fields = [];
130
+ for (const part of parts) {
131
+ if (!known.has(part)) {
132
+ throw new AxiError(`Unknown contact property ${part} in --${flag}`, "VALIDATION_ERROR", [
133
+ help,
134
+ `Known contact properties: ${knownList.join(", ")}`,
135
+ ]);
136
+ }
137
+ if (!fields.includes(part))
138
+ fields.push(part);
139
+ }
140
+ return fields;
141
+ }
142
+ function scopesFor(flags, defaults, profile, help) {
143
+ if (profile.mode === "application") {
144
+ if (flags.scopes !== undefined) {
145
+ throw new AxiError("Application profiles use the configured Graph .default audience; delegated scopes are unavailable", "VALIDATION_ERROR", [help, "mg-axi profile show --profile <name>"]);
146
+ }
147
+ return undefined;
148
+ }
149
+ if (flags.scopes === undefined)
150
+ return [...defaults];
151
+ const scopes = String(flags.scopes)
152
+ .split(",")
153
+ .map(scope => scope.trim())
154
+ .filter(scope => scope.length > 0);
155
+ return scopes;
156
+ }
157
+ function truncateValue(value, full) {
158
+ const truncate = (text) => full || text.length <= TRUNCATE_AT
159
+ ? { value: text, truncated: false }
160
+ : { value: `${text.slice(0, TRUNCATE_AT)}... (truncated, ${text.length} chars total)`, truncated: true };
161
+ if (typeof value === "string")
162
+ return truncate(value);
163
+ if (Array.isArray(value) && value.every(entry => typeof entry === "string")) {
164
+ const projected = value.map(truncate);
165
+ return { value: projected.map(entry => entry.value), truncated: projected.some(entry => entry.truncated) };
166
+ }
167
+ return { value, truncated: false };
168
+ }
169
+ // Local projection preserves Graph's null/missing distinction: an explicit
170
+ // null stays null, an absent property stays absent and is never synthesized.
171
+ function project(row, fields, full) {
172
+ const source = row !== null && typeof row === "object" && !Array.isArray(row) ? row : {};
173
+ const projected = {};
174
+ let truncated = false;
175
+ for (const field of fields) {
176
+ if (!Object.hasOwn(source, field))
177
+ continue;
178
+ const result = truncateValue(source[field], full);
179
+ projected[field] = result.value;
180
+ truncated = truncated || result.truncated;
181
+ }
182
+ return { row: projected, truncated };
183
+ }
184
+ function selectedFields(flags, defaults, help) {
185
+ const select = flags.select === undefined ? [...defaults] : fieldList(flags.select, KNOWN_CONTACTS, KNOWN_CONTACT_FIELDS, "select", help);
186
+ const fields = flags.fields === undefined ? [...select] : fieldList(flags.fields, KNOWN_CONTACTS, KNOWN_CONTACT_FIELDS, "fields", help);
187
+ const missing = fields.find(field => !select.includes(field));
188
+ if (missing) {
189
+ throw new AxiError(`--fields ${missing} was not fetched; request it with --select`, "VALIDATION_ERROR", [help]);
190
+ }
191
+ return { select, fields };
192
+ }
193
+ function profileHint(profileName) {
194
+ return `--profile ${shellValue(profileName)}`;
195
+ }
196
+ function shellValue(value) {
197
+ return /^[A-Za-z0-9_.,:/@=-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
198
+ }
199
+ function fullHint(command, flags, profileName) {
200
+ const args = Object.entries({ ...flags, profile: profileName, full: true })
201
+ .map(([name, value]) => value === true ? `--${name}` : `--${name} ${shellValue(String(value))}`);
202
+ return `mg-axi ${command} ${args.join(" ")}`;
203
+ }
204
+ // Denials carry the session's generic grant/role/licence cause; each
205
+ // operation adds the scope, roles and licensing that actually unlock it,
206
+ // because a 403 alone never says which prerequisite is missing.
207
+ function withGuidance(hints, run) {
208
+ return run().catch(error => {
209
+ if (error instanceof AxiError && error.code === "GRAPH_ERROR" && /\(403\)/.test(error.message)) {
210
+ throw new AxiError(error.message, "GRAPH_ERROR", [...hints, ...error.suggestions]);
211
+ }
212
+ throw error;
213
+ });
214
+ }
215
+ const EMPTY_NOTE = "0 organizational contacts returned; tenants without synced or Exchange-provisioned contacts carry none, so the absence of rows is the answer, not an error";
216
+ const CONTACT_DENIAL_HINTS = [
217
+ "Contact reads need OrgContact.Read.All for delegated or application access (Directory.Read.All is a documented higher-privileged alternative); delegated callers pass it as --scopes",
218
+ "Delegated contact reads additionally need a supported Entra role: Directory Readers reads basic properties, and Global Reader, Directory Writers, Intune Administrator or User Administrator also work",
219
+ "Personal Microsoft accounts are not supported for contact reads",
220
+ "No P1/P2 prerequisite is stated for contact reads; never diagnose role or licence solely from HTTP 403",
221
+ ];
222
+ // Restoring the saved select and filter keeps cursor resumes lossless when
223
+ // --select/--filter are omitted. --filter passes through as plain $filter
224
+ // with $count=true and ConsistencyLevel eventual, the documented
225
+ // advanced-query contract for directory-object filtering; $search and
226
+ // $orderby stay unreviewed and strict input validation refuses them before
227
+ // credentials.
228
+ function collectionCommon(session, flags, operation, help, profile) {
229
+ const cursor = flags.cursor === undefined ? undefined : String(flags.cursor);
230
+ if (cursor !== undefined && !cursor.trim())
231
+ throw new AxiError("--cursor needs the opaque cursor from a partial result", "VALIDATION_ERROR", [help]);
232
+ const saved = cursor === undefined ? undefined : session.cursorQuery(operation, cursor);
233
+ const { select, fields } = selectedFields(flags, saved?.["$select"] === undefined ? DEFAULT_LIST_SELECT : fieldList(saved["$select"], KNOWN_CONTACTS, KNOWN_CONTACT_FIELDS, "select", help), help);
234
+ const filter = flags.filter === undefined ? saved?.["$filter"] : String(flags.filter);
235
+ return { cursor, select, fields, filter, scopes: scopesFor(flags, DEFAULT_CONTACT_SCOPES, profile, help), full: flags.full === true };
236
+ }
237
+ function collectArgs(profile, operation, common, flags, help) {
238
+ const query = { $select: common.select.join(",") };
239
+ if (common.filter !== undefined) {
240
+ query.$filter = common.filter;
241
+ query.$count = "true";
242
+ }
243
+ const args = { profile, operation, query, scopes: common.scopes };
244
+ if (common.filter !== undefined)
245
+ args.consistencyLevel = "eventual";
246
+ if (common.cursor !== undefined)
247
+ args.cursor = common.cursor;
248
+ if (flags.all === true) {
249
+ if (flags.limit !== undefined)
250
+ throw new AxiError("--limit and --all cannot be combined", "VALIDATION_ERROR", [help]);
251
+ }
252
+ else {
253
+ args.limit = flags.limit === undefined ? 100 : Number(flags.limit);
254
+ }
255
+ return args;
256
+ }
257
+ function resumeHint(profileName) {
258
+ return `Resume losslessly with the same flags plus --cursor <cursor-from-output> ${profileHint(profileName)}`;
259
+ }
260
+ function contactId(flags, help) {
261
+ const id = String(flags.id);
262
+ if (!id.trim())
263
+ throw new AxiError("--id needs the organizational-contact object ID", "VALIDATION_ERROR", [help]);
264
+ return id;
265
+ }
266
+ function singleResult(raw, fields, full) {
267
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
268
+ throw new AxiError("Graph returned a malformed contact body", "GRAPH_ERROR", [
269
+ "Single-contact reads carry one orgContact; treat anything else as unknown, not empty",
270
+ ]);
271
+ }
272
+ return project(raw, fields, full);
273
+ }
274
+ export async function listContacts(session, flags, profile, operation, help, profileName) {
275
+ checkReadOperation(operation, help);
276
+ const common = collectionCommon(session, flags, operation, help, profile);
277
+ const result = await withGuidance(CONTACT_DENIAL_HINTS, () => session.collect(collectArgs(profile, operation, common, flags, help)));
278
+ const effectiveFlags = { ...flags, select: result.query.$select ?? DEFAULT_LIST_SELECT.join(",") };
279
+ if (result.query.$filter !== undefined)
280
+ effectiveFlags.filter = result.query.$filter;
281
+ const contacts = [];
282
+ let truncated = false;
283
+ for (const row of result.value) {
284
+ const projected = project(row, common.fields, common.full);
285
+ contacts.push(projected.row);
286
+ truncated = truncated || projected.truncated;
287
+ }
288
+ const showHint = `mg-axi entra contact show --id <contact-id> ${profileHint(profileName)}`;
289
+ const truncationHints = truncated ? [fullHint("entra contact list", effectiveFlags, profileName)] : [];
290
+ if (!result.complete) {
291
+ return {
292
+ contacts,
293
+ count: { returned: contacts.length, complete: false, reason: result.reason },
294
+ cursor: result.cursor,
295
+ help: [...truncationHints, resumeHint(profileName), showHint],
296
+ };
297
+ }
298
+ const count = { returned: contacts.length, complete: true };
299
+ if (!contacts.length) {
300
+ return {
301
+ contacts,
302
+ count,
303
+ help: [
304
+ `mg-axi entra contact list --filter <odata-filter> ${profileHint(profileName)}`,
305
+ "0 contacts matched; the absence of results is the answer, not an error",
306
+ ],
307
+ };
308
+ }
309
+ return { contacts, count, help: [...truncationHints, showHint] };
310
+ }
311
+ export async function showContact(session, flags, profile, operation, help, profileName) {
312
+ checkReadOperation(operation, help);
313
+ const { select, fields } = selectedFields(flags, DEFAULT_SHOW_SELECT, help);
314
+ const scopes = scopesFor(flags, DEFAULT_CONTACT_SCOPES, profile, help);
315
+ const full = flags.full === true;
316
+ const id = contactId(flags, help);
317
+ const raw = await withGuidance(CONTACT_DENIAL_HINTS, () => session.execute({
318
+ profile,
319
+ operation,
320
+ params: { "orgContact-id": id },
321
+ query: { $select: select.join(",") },
322
+ scopes,
323
+ }));
324
+ const { row, truncated } = singleResult(raw, fields, full);
325
+ if (truncated)
326
+ return { contact: row, help: [fullHint("entra contact show", flags, profileName)] };
327
+ return { contact: row };
328
+ }
329
+ // The $count route returns a text/plain integer scalar rather than a JSON
330
+ // collection, so the leaf reads it through session.execute and accepts only
331
+ // a non-negative integer. The list page's $count example sends
332
+ // ConsistencyLevel eventual, so the scalar declares it too. There is no
333
+ // --filter/--select/--limit contract on the count: the catalogue declares no
334
+ // such flags and strict input validation refuses them before credentials.
335
+ export async function countContacts(session, flags, profile, operation, help, profileName) {
336
+ void profileName;
337
+ checkReadOperation(operation, help);
338
+ const scopes = scopesFor(flags, DEFAULT_CONTACT_SCOPES, profile, help);
339
+ const raw = await withGuidance(CONTACT_DENIAL_HINTS, () => session.execute({ profile, operation, scopes, consistencyLevel: "eventual", scalar: true }));
340
+ if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 0) {
341
+ throw new AxiError("Graph returned a malformed contact count body", "GRAPH_ERROR", [
342
+ "Contact counts carry one non-negative integer scalar; treat anything else as unknown, not empty",
343
+ ]);
344
+ }
345
+ const count = { returned: raw, complete: true };
346
+ return raw === 0 ? { count, help: [EMPTY_NOTE] } : { count };
347
+ }
348
+ // EXT-01m navigation (second part): the read mapping behind
349
+ // `mg-axi entra contact show-manager`, `list-direct-reports`,
350
+ // `show-direct-report` and `count-direct-reports`. Results are directory
351
+ // objects of mixed user/contact type, so rows carry the @odata.type
352
+ // discriminator plus minimal personal-data fields by default (id and
353
+ // displayName); mail needs an explicit --select and richer per-type fields
354
+ // need the subtype's single-object reads.
355
+ //
356
+ // Reviewed against the v1.0 orgcontact-get-manager and
357
+ // orgcontact-list-directreports operation documentation plus the orgContact
358
+ // resource reference on 2026-10-05. Both reads take delegated or
359
+ // application OrgContact.Read.All, the documented least privilege in each
360
+ // mode (Directory.Read.All, Directory.ReadWrite.All and Group.Read.All are
361
+ // documented only as higher-privileged alternatives); personal Microsoft
362
+ // accounts are not supported. Delegated callers additionally need a
363
+ // supported Entra role: Directory Readers reads basic properties, and
364
+ // Global Reader, Directory Writers, Intune Administrator or User
365
+ // Administrator also work. No P1/P2 prerequisite is stated for these reads.
366
+ // The directReports page documents $select only, so $filter/$search/$top
367
+ // stay unreviewed and strict input validation refuses them before
368
+ // credentials. The $count scalars carry no operation-level documentation
369
+ // page and follow the contacts $count contract (ConsistencyLevel eventual).
370
+ // The typed casts (graph.user, graph.orgContact) select one subtype through
371
+ // the documented OData-cast route with the same permission contract. The
372
+ // memberOf/transitiveMemberOf membership reads live in the EXT-01n block
373
+ // below; serviceProvisioningErrors and onPremisesSyncBehavior stay out as
374
+ // unavailable with no documented permission contract. Application callers with narrow consent receive
375
+ // limited-information rows carrying only @odata.type and id; those rows are
376
+ // preserved, never reinterpreted as empty.
377
+ // Every navigation property this slice may request or display. Users and
378
+ // contacts both carry these; anything else fails before credentials.
379
+ export const KNOWN_NAV_FIELDS = ["id", "displayName", "mail"];
380
+ const KNOWN_NAV = new Set(KNOWN_NAV_FIELDS);
381
+ // @odata.type is preserved on navigation rows without being selectable: it
382
+ // names the directory-object kind and marks limited-information rows.
383
+ const NAV_TYPE_PROPERTY = "@odata.type";
384
+ // Compact navigation rows stay minimal on purpose: id and displayName only.
385
+ const DEFAULT_NAV_SELECT = ["id", "displayName"];
386
+ // The --as values mirror the Graph subtype names in the cast routes.
387
+ export const NAV_CAST_VALUES = ["user", "orgContact"];
388
+ // Base direct-report operation to its typed-cast counterparts. --as selects
389
+ // between these catalogued routes; anything else is refused before
390
+ // credentials and never falls back to the unfiltered route silently.
391
+ const NAV_CASTS = {
392
+ "GET:/contacts/{orgContact-id}/directReports": {
393
+ user: "GET:/contacts/{orgContact-id}/directReports/graph.user",
394
+ orgContact: "GET:/contacts/{orgContact-id}/directReports/graph.orgContact",
395
+ },
396
+ "GET:/contacts/{orgContact-id}/directReports/$count": {
397
+ user: "GET:/contacts/{orgContact-id}/directReports/graph.user/$count",
398
+ orgContact: "GET:/contacts/{orgContact-id}/directReports/graph.orgContact/$count",
399
+ },
400
+ "GET:/contacts/{orgContact-id}/directReports/{directoryObject-id}": {
401
+ user: "GET:/contacts/{orgContact-id}/directReports/{directoryObject-id}/graph.user",
402
+ orgContact: "GET:/contacts/{orgContact-id}/directReports/{directoryObject-id}/graph.orgContact",
403
+ },
404
+ };
405
+ export function castDirectReports(base, raw, help) {
406
+ const value = String(raw);
407
+ const alternate = NAV_CASTS[base]?.[value];
408
+ if (!alternate) {
409
+ throw new AxiError(`--as ${value} is not a direct-report cast`, "VALIDATION_ERROR", [
410
+ help,
411
+ `--as takes ${NAV_CAST_VALUES.join(" or ")} on direct-report list, show and count reads only`,
412
+ ]);
413
+ }
414
+ return alternate;
415
+ }
416
+ // Navigation projection additionally preserves @odata.type so the
417
+ // directory-object kind and limited-information rows survive projection.
418
+ function projectNav(row, fields, full) {
419
+ const { row: projected, truncated } = project(row, fields, full);
420
+ const source = row !== null && typeof row === "object" && !Array.isArray(row) ? row : {};
421
+ if (typeof source[NAV_TYPE_PROPERTY] === "string")
422
+ projected[NAV_TYPE_PROPERTY] = source[NAV_TYPE_PROPERTY];
423
+ const descriptive = [...new Set([...fields, ...Object.keys(source)])]
424
+ .filter(field => field !== "id" && !field.startsWith("@"));
425
+ const limitedInfo = Object.hasOwn(source, "id") && descriptive.length > 0
426
+ && descriptive.every(field => source[field] === null || source[field] === undefined || !Object.hasOwn(source, field));
427
+ return { row: projected, truncated, limitedInfo };
428
+ }
429
+ // The directReports page documents $select only: no $filter passthrough and
430
+ // no ConsistencyLevel contract here, so strict validation refuses filter
431
+ // flags before credentials and cursor resumes restore only the $select.
432
+ function navCollectionCommon(session, flags, operation, help, profile) {
433
+ const cursor = flags.cursor === undefined ? undefined : String(flags.cursor);
434
+ if (cursor !== undefined && !cursor.trim())
435
+ throw new AxiError("--cursor needs the opaque cursor from a partial result", "VALIDATION_ERROR", [help]);
436
+ const saved = cursor === undefined ? undefined : session.cursorQuery(operation, cursor);
437
+ const defaults = saved?.["$select"] === undefined ? [...DEFAULT_NAV_SELECT] : fieldList(saved["$select"], KNOWN_NAV, KNOWN_NAV_FIELDS, "select", help);
438
+ const { select, fields } = navFieldsFor(flags, help, defaults);
439
+ return { cursor, select, fields, scopes: scopesFor(flags, DEFAULT_CONTACT_SCOPES, profile, help), full: flags.full === true };
440
+ }
441
+ function navCollectArgs(profile, operation, common, flags, help) {
442
+ const args = { profile, operation, query: { $select: common.select.join(",") }, scopes: common.scopes };
443
+ if (common.cursor !== undefined)
444
+ args.cursor = common.cursor;
445
+ if (flags.all === true) {
446
+ if (flags.limit !== undefined)
447
+ throw new AxiError("--limit and --all cannot be combined", "VALIDATION_ERROR", [help]);
448
+ }
449
+ else {
450
+ args.limit = flags.limit === undefined ? 100 : Number(flags.limit);
451
+ }
452
+ return args;
453
+ }
454
+ function navSingleResult(raw, fields, full, noun) {
455
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
456
+ throw new AxiError(`Graph returned a malformed ${noun} body`, "GRAPH_ERROR", [
457
+ `${noun} reads carry one directoryObject; treat anything else as unknown, not empty`,
458
+ ]);
459
+ }
460
+ return projectNav(raw, fields, full);
461
+ }
462
+ function navFieldsFor(flags, help, defaults = DEFAULT_NAV_SELECT) {
463
+ const select = flags.select === undefined ? [...defaults] : fieldList(flags.select, KNOWN_NAV, KNOWN_NAV_FIELDS, "select", help);
464
+ const fields = flags.fields === undefined ? [...select] : fieldList(flags.fields, KNOWN_NAV, KNOWN_NAV_FIELDS, "fields", help);
465
+ const missing = fields.find(field => !select.includes(field));
466
+ if (missing) {
467
+ throw new AxiError(`--fields ${missing} was not fetched; request it with --select`, "VALIDATION_ERROR", [help]);
468
+ }
469
+ return { select, fields };
470
+ }
471
+ export async function showContactManager(session, flags, profile, operation, help, profileName) {
472
+ checkReadOperation(operation, help);
473
+ const { select, fields } = navFieldsFor(flags, help);
474
+ const scopes = scopesFor(flags, DEFAULT_CONTACT_SCOPES, profile, help);
475
+ const full = flags.full === true;
476
+ const id = contactId(flags, help);
477
+ const raw = await withGuidance(CONTACT_DENIAL_HINTS, () => session.execute({
478
+ profile,
479
+ operation,
480
+ params: { "orgContact-id": id },
481
+ query: { $select: select.join(",") },
482
+ scopes,
483
+ }));
484
+ const { row, truncated } = navSingleResult(raw, fields, full, "manager");
485
+ if (truncated)
486
+ return { manager: row, help: [fullHint("entra contact show-manager", flags, profileName)] };
487
+ return { manager: row };
488
+ }
489
+ export async function listContactDirectReports(session, flags, profile, operation, help, profileName) {
490
+ checkReadOperation(operation, help);
491
+ const common = navCollectionCommon(session, flags, operation, help, profile);
492
+ const id = contactId(flags, help);
493
+ const args = navCollectArgs(profile, operation, common, flags, help);
494
+ args.params = { "orgContact-id": id };
495
+ const result = await withGuidance(CONTACT_DENIAL_HINTS, () => session.collect(args));
496
+ const effectiveFlags = { ...flags, select: result.query.$select ?? DEFAULT_NAV_SELECT.join(",") };
497
+ const directReports = [];
498
+ let truncated = false;
499
+ let limitedInfo = 0;
500
+ for (const row of result.value) {
501
+ const projected = projectNav(row, common.fields, common.full);
502
+ directReports.push(projected.row);
503
+ truncated = truncated || projected.truncated;
504
+ if (projected.limitedInfo)
505
+ limitedInfo += 1;
506
+ }
507
+ const showHint = `mg-axi entra contact show-direct-report --id <contact-id> --report-id <report-id> ${profileHint(profileName)}`;
508
+ const truncationHints = truncated ? [fullHint("entra contact list-direct-reports", effectiveFlags, profileName)] : [];
509
+ const limitedHints = limitedInfo > 0
510
+ ? [`${limitedInfo} of ${directReports.length} rows carry only type and id; this may reflect limited read consent or unset properties`]
511
+ : [];
512
+ if (!result.complete) {
513
+ return {
514
+ directReports,
515
+ count: { returned: directReports.length, complete: false, reason: result.reason },
516
+ cursor: result.cursor,
517
+ help: [...truncationHints, ...limitedHints, resumeHint(profileName), showHint],
518
+ };
519
+ }
520
+ const count = { returned: directReports.length, complete: true };
521
+ if (!directReports.length) {
522
+ return {
523
+ directReports,
524
+ count,
525
+ help: [...limitedHints, "0 direct reports matched; the absence of results is the answer, not an error", showHint],
526
+ };
527
+ }
528
+ return { directReports, count, help: [...truncationHints, ...limitedHints, showHint] };
529
+ }
530
+ export async function showContactDirectReport(session, flags, profile, operation, help, profileName) {
531
+ checkReadOperation(operation, help);
532
+ const { select, fields } = navFieldsFor(flags, help);
533
+ const scopes = scopesFor(flags, DEFAULT_CONTACT_SCOPES, profile, help);
534
+ const full = flags.full === true;
535
+ const id = contactId(flags, help);
536
+ const reportId = String(flags["report-id"]);
537
+ if (!reportId.trim())
538
+ throw new AxiError("--report-id needs the direct-report directory-object ID", "VALIDATION_ERROR", [help]);
539
+ const raw = await withGuidance(CONTACT_DENIAL_HINTS, () => session.execute({
540
+ profile,
541
+ operation,
542
+ params: { "orgContact-id": id, "directoryObject-id": reportId },
543
+ query: { $select: select.join(",") },
544
+ scopes,
545
+ }));
546
+ const { row, truncated } = navSingleResult(raw, fields, full, "direct-report");
547
+ if (truncated)
548
+ return { directReport: row, help: [fullHint("entra contact show-direct-report", flags, profileName)] };
549
+ return { directReport: row };
550
+ }
551
+ // The $count routes return a text/plain integer scalar rather than a JSON
552
+ // collection, so the leaf reads it through session.execute and accepts only
553
+ // a non-negative integer. The scalars carry no operation-level
554
+ // documentation page and follow the contacts $count contract
555
+ // (ConsistencyLevel eventual). There is no --select/--limit contract on the
556
+ // count: strict input validation refuses those flags before credentials.
557
+ export async function countContactDirectReports(session, flags, profile, operation, help, profileName) {
558
+ void profileName;
559
+ checkReadOperation(operation, help);
560
+ const scopes = scopesFor(flags, DEFAULT_CONTACT_SCOPES, profile, help);
561
+ const id = contactId(flags, help);
562
+ const raw = await withGuidance(CONTACT_DENIAL_HINTS, () => session.execute({
563
+ profile, operation, params: { "orgContact-id": id }, scopes, consistencyLevel: "eventual", scalar: true,
564
+ }));
565
+ if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 0) {
566
+ throw new AxiError("Graph returned a malformed direct-report count body", "GRAPH_ERROR", [
567
+ "Direct-report counts carry one non-negative integer scalar; treat anything else as unknown, not empty",
568
+ ]);
569
+ }
570
+ const count = { returned: raw, complete: true };
571
+ return raw === 0
572
+ ? { count, help: ["0 direct reports returned; a contact with no reports set as their manager carries none, so the absence of rows is the answer, not an error"] }
573
+ : { count };
574
+ }
575
+ // EXT-01n membership (third part): the read mapping behind
576
+ // `mg-axi entra contact list-member-of`, `show-member-of` and
577
+ // `count-member-of`. Results are directory objects of group or
578
+ // administrativeUnit type, so rows carry the @odata.type discriminator plus
579
+ // minimal fields by default (id and displayName); mail needs an explicit
580
+ // --select and richer per-type fields need the subtype's single-object
581
+ // reads. --transitive selects the transitiveMemberOf flat closure instead of
582
+ // direct memberOf, and --as selects one typed cast route (graph.group or
583
+ // graph.administrativeUnit).
584
+ //
585
+ // Reviewed against the v1.0 orgcontact-list-memberof and
586
+ // orgcontact-list-transitivememberof operation documentation plus the
587
+ // orgContact resource reference on 2026-10-05. Direct reads take delegated
588
+ // or application OrgContact.Read.All, the documented least privilege in each
589
+ // mode (Directory.Read.All, Directory.ReadWrite.All and Group.Read.All are
590
+ // documented only as higher-privileged alternatives). Transitive reads take
591
+ // delegated or application OrgContact.Read.All and Group.Read.All together,
592
+ // the documented least privilege in each mode (Directory.Read.All is the
593
+ // documented higher-privileged alternative); both scopes are already in the
594
+ // shared READ_SCOPES allowlist, so no deferral is needed here. Delegated
595
+ // callers additionally need a supported Entra role: Directory Readers reads
596
+ // basic properties, and Global Reader, Directory Writers, Intune
597
+ // Administrator or User Administrator also work. Personal Microsoft accounts
598
+ // are not supported and no P1/P2 prerequisite is stated for these reads.
599
+ // The list pages document $filter, $count, $select, $search and $top with
600
+ // advanced query parameters only, so --filter passes through as plain
601
+ // $filter with $count=true and ConsistencyLevel eventual while $search and
602
+ // $orderby stay unreviewed and strict input validation refuses them before
603
+ // credentials. The $count scalars and the single/cast routes carry no
604
+ // operation-level documentation page and follow the parent list contract
605
+ // ($select only on singles, ConsistencyLevel eventual on scalars). The typed
606
+ // casts (graph.group, graph.administrativeUnit) select one subtype through
607
+ // the documented OData-cast route with the same permission contract.
608
+ // Application callers with narrow consent receive limited-information rows
609
+ // carrying only @odata.type and id; those rows are preserved, never
610
+ // reinterpreted as empty.
611
+ // Direct reads default to OrgContact.Read.All; transitive reads additionally
612
+ // need Group.Read.All. Both are read scopes already allowlisted.
613
+ export const DEFAULT_MEMBER_OF_SCOPES = ["https://graph.microsoft.com/OrgContact.Read.All"];
614
+ export const DEFAULT_TRANSITIVE_MEMBER_OF_SCOPES = [
615
+ "https://graph.microsoft.com/OrgContact.Read.All",
616
+ "https://graph.microsoft.com/Group.Read.All",
617
+ ];
618
+ // The --as values mirror the Graph subtype names in the cast routes.
619
+ export const MEMBERSHIP_CAST_VALUES = ["group", "administrativeUnit"];
620
+ // Direct membership operation to its transitive flat-view counterpart.
621
+ // --transitive selects between these two catalogued routes; anything else is
622
+ // refused before credentials and never falls back silently.
623
+ const MEMBERSHIP_TRANSITIVE = {
624
+ "GET:/contacts/{orgContact-id}/memberOf": "GET:/contacts/{orgContact-id}/transitiveMemberOf",
625
+ "GET:/contacts/{orgContact-id}/memberOf/$count": "GET:/contacts/{orgContact-id}/transitiveMemberOf/$count",
626
+ "GET:/contacts/{orgContact-id}/memberOf/{directoryObject-id}": "GET:/contacts/{orgContact-id}/transitiveMemberOf/{directoryObject-id}",
627
+ };
628
+ export function transitMembership(base, help) {
629
+ const alternate = MEMBERSHIP_TRANSITIVE[base];
630
+ if (!alternate) {
631
+ throw new AxiError("--transitive is available for contact member-of reads only", "VALIDATION_ERROR", [help]);
632
+ }
633
+ return alternate;
634
+ }
635
+ // Uncast membership operation to its typed-cast counterparts. --as selects
636
+ // between these catalogued routes; anything else is refused before
637
+ // credentials and never falls back to the unfiltered route silently.
638
+ const MEMBERSHIP_CASTS = {
639
+ "GET:/contacts/{orgContact-id}/memberOf": {
640
+ group: "GET:/contacts/{orgContact-id}/memberOf/graph.group",
641
+ administrativeUnit: "GET:/contacts/{orgContact-id}/memberOf/graph.administrativeUnit",
642
+ },
643
+ "GET:/contacts/{orgContact-id}/memberOf/$count": {
644
+ group: "GET:/contacts/{orgContact-id}/memberOf/graph.group/$count",
645
+ administrativeUnit: "GET:/contacts/{orgContact-id}/memberOf/graph.administrativeUnit/$count",
646
+ },
647
+ "GET:/contacts/{orgContact-id}/memberOf/{directoryObject-id}": {
648
+ group: "GET:/contacts/{orgContact-id}/memberOf/{directoryObject-id}/graph.group",
649
+ administrativeUnit: "GET:/contacts/{orgContact-id}/memberOf/{directoryObject-id}/graph.administrativeUnit",
650
+ },
651
+ "GET:/contacts/{orgContact-id}/transitiveMemberOf": {
652
+ group: "GET:/contacts/{orgContact-id}/transitiveMemberOf/graph.group",
653
+ administrativeUnit: "GET:/contacts/{orgContact-id}/transitiveMemberOf/graph.administrativeUnit",
654
+ },
655
+ "GET:/contacts/{orgContact-id}/transitiveMemberOf/$count": {
656
+ group: "GET:/contacts/{orgContact-id}/transitiveMemberOf/graph.group/$count",
657
+ administrativeUnit: "GET:/contacts/{orgContact-id}/transitiveMemberOf/graph.administrativeUnit/$count",
658
+ },
659
+ "GET:/contacts/{orgContact-id}/transitiveMemberOf/{directoryObject-id}": {
660
+ group: "GET:/contacts/{orgContact-id}/transitiveMemberOf/{directoryObject-id}/graph.group",
661
+ administrativeUnit: "GET:/contacts/{orgContact-id}/transitiveMemberOf/{directoryObject-id}/graph.administrativeUnit",
662
+ },
663
+ };
664
+ export function castMembership(base, raw, help) {
665
+ const value = String(raw);
666
+ const alternate = MEMBERSHIP_CASTS[base]?.[value];
667
+ if (!alternate) {
668
+ throw new AxiError(`--as ${value} is not a member-of cast`, "VALIDATION_ERROR", [
669
+ help,
670
+ `--as takes ${MEMBERSHIP_CAST_VALUES.join(" or ")} on member-of list, show and count reads only`,
671
+ ]);
672
+ }
673
+ return alternate;
674
+ }
675
+ function membershipScopesFor(flags, profile, help) {
676
+ const defaults = flags.transitive === true ? DEFAULT_TRANSITIVE_MEMBER_OF_SCOPES : DEFAULT_MEMBER_OF_SCOPES;
677
+ return scopesFor(flags, defaults, profile, help);
678
+ }
679
+ const MEMBER_OF_DENIAL_HINTS = [
680
+ "Contact memberOf reads need OrgContact.Read.All for delegated or application access (Directory.Read.All, Directory.ReadWrite.All and Group.Read.All are documented higher-privileged alternatives); delegated callers pass it as --scopes",
681
+ "Delegated memberOf reads additionally need a supported Entra role: Directory Readers reads basic properties, and Global Reader, Directory Writers, Intune Administrator or User Administrator also work",
682
+ "Personal Microsoft accounts are not supported for memberOf reads",
683
+ "No P1/P2 prerequisite is stated for memberOf reads; never diagnose role or licence solely from HTTP 403",
684
+ ];
685
+ const TRANSITIVE_MEMBER_OF_DENIAL_HINTS = [
686
+ "Contact transitiveMemberOf reads need OrgContact.Read.All and Group.Read.All together for delegated or application access (Directory.Read.All is the documented higher-privileged alternative); delegated callers pass both as --scopes",
687
+ "With only one of the two scopes, groups outside the granted type return limited-information rows carrying only type and id",
688
+ "Delegated transitiveMemberOf reads additionally need a supported Entra role: Directory Readers reads basic properties, and Global Reader, Directory Writers, Intune Administrator or User Administrator also work",
689
+ "Personal Microsoft accounts are not supported for transitiveMemberOf reads",
690
+ "No P1/P2 prerequisite is stated for transitiveMemberOf reads; never diagnose role or licence solely from HTTP 403",
691
+ ];
692
+ function membershipDenialHints(flags) {
693
+ return flags.transitive === true ? TRANSITIVE_MEMBER_OF_DENIAL_HINTS : MEMBER_OF_DENIAL_HINTS;
694
+ }
695
+ // The memberOf pages document $filter alongside $select with advanced query
696
+ // parameters only, so --filter passes through as plain $filter with
697
+ // $count=true and ConsistencyLevel eventual (the groups memberOf contract);
698
+ // $search and $orderby stay unreviewed. Restoring the saved select and
699
+ // filter keeps cursor resumes lossless when --select/--filter are omitted.
700
+ function membershipCollectionCommon(session, flags, operation, help, profile) {
701
+ const cursor = flags.cursor === undefined ? undefined : String(flags.cursor);
702
+ if (cursor !== undefined && !cursor.trim())
703
+ throw new AxiError("--cursor needs the opaque cursor from a partial result", "VALIDATION_ERROR", [help]);
704
+ const saved = cursor === undefined ? undefined : session.cursorQuery(operation, cursor);
705
+ const defaults = saved?.["$select"] === undefined ? [...DEFAULT_NAV_SELECT] : fieldList(saved["$select"], KNOWN_NAV, KNOWN_NAV_FIELDS, "select", help);
706
+ const { select, fields } = navFieldsFor(flags, help, defaults);
707
+ const filter = flags.filter === undefined ? saved?.["$filter"] : String(flags.filter);
708
+ return { cursor, select, fields, scopes: membershipScopesFor(flags, profile, help), full: flags.full === true, filter };
709
+ }
710
+ function membershipCollectArgs(profile, operation, common, flags, help) {
711
+ const query = { $select: common.select.join(",") };
712
+ if (common.filter !== undefined) {
713
+ query.$filter = common.filter;
714
+ query.$count = "true";
715
+ }
716
+ const args = { profile, operation, query, scopes: common.scopes };
717
+ if (common.filter !== undefined)
718
+ args.consistencyLevel = "eventual";
719
+ if (common.cursor !== undefined)
720
+ args.cursor = common.cursor;
721
+ if (flags.all === true) {
722
+ if (flags.limit !== undefined)
723
+ throw new AxiError("--limit and --all cannot be combined", "VALIDATION_ERROR", [help]);
724
+ }
725
+ else {
726
+ args.limit = flags.limit === undefined ? 100 : Number(flags.limit);
727
+ }
728
+ return args;
729
+ }
730
+ function membershipId(flags, help) {
731
+ const id = flags["member-id"] === undefined ? "" : String(flags["member-id"]);
732
+ if (!id.trim())
733
+ throw new AxiError("--member-id needs the membership directory-object ID", "VALIDATION_ERROR", [help]);
734
+ return id;
735
+ }
736
+ export async function listContactMemberOf(session, flags, profile, operation, help, profileName) {
737
+ checkReadOperation(operation, help);
738
+ const transitive = flags.transitive === true;
739
+ const common = membershipCollectionCommon(session, flags, operation, help, profile);
740
+ const id = contactId(flags, help);
741
+ const args = membershipCollectArgs(profile, operation, common, flags, help);
742
+ args.params = { "orgContact-id": id };
743
+ const result = await withGuidance(membershipDenialHints(flags), () => session.collect(args));
744
+ const effectiveFlags = { ...flags, select: result.query.$select ?? DEFAULT_NAV_SELECT.join(",") };
745
+ if (result.query.$filter !== undefined)
746
+ effectiveFlags.filter = result.query.$filter;
747
+ const memberOf = [];
748
+ let truncated = false;
749
+ let limitedInfo = 0;
750
+ for (const row of result.value) {
751
+ const projected = projectNav(row, common.fields, common.full);
752
+ memberOf.push(projected.row);
753
+ truncated = truncated || projected.truncated;
754
+ if (projected.limitedInfo)
755
+ limitedInfo += 1;
756
+ }
757
+ const noun = transitive ? "transitiveMemberOf" : "memberOf";
758
+ const command = "entra contact list-member-of";
759
+ const showHint = `mg-axi entra contact show-member-of --id <contact-id> --member-id <membership-id>${transitive ? " --transitive" : ""} ${profileHint(profileName)}`;
760
+ const scopeHint = transitive
761
+ ? `Direct memberships only: mg-axi ${command} --id ${shellValue(id)} ${profileHint(profileName)}`
762
+ : `Flat nested view: mg-axi ${command} --id ${shellValue(id)} --transitive ${profileHint(profileName)}`;
763
+ const truncationHints = truncated ? [fullHint(command, effectiveFlags, profileName)] : [];
764
+ const limitedHints = limitedInfo > 0
765
+ ? [`${limitedInfo} of ${memberOf.length} rows carry only type and id; this may reflect limited read consent or unset properties`]
766
+ : [];
767
+ if (!result.complete) {
768
+ return {
769
+ memberOf,
770
+ count: { returned: memberOf.length, complete: false, reason: result.reason },
771
+ cursor: result.cursor,
772
+ help: [...truncationHints, ...limitedHints, resumeHint(profileName), scopeHint, showHint],
773
+ };
774
+ }
775
+ const count = { returned: memberOf.length, complete: true };
776
+ if (!memberOf.length) {
777
+ return {
778
+ memberOf,
779
+ count,
780
+ help: [...limitedHints, `0 ${noun} matched; the absence of results is the answer, not an error`, scopeHint, showHint],
781
+ };
782
+ }
783
+ return { memberOf, count, help: [...truncationHints, ...limitedHints, scopeHint, showHint] };
784
+ }
785
+ export async function showContactMemberOf(session, flags, profile, operation, help, profileName) {
786
+ checkReadOperation(operation, help);
787
+ const { select, fields } = navFieldsFor(flags, help);
788
+ const scopes = membershipScopesFor(flags, profile, help);
789
+ const full = flags.full === true;
790
+ const id = contactId(flags, help);
791
+ const memberId = membershipId(flags, help);
792
+ const raw = await withGuidance(membershipDenialHints(flags), () => session.execute({
793
+ profile,
794
+ operation,
795
+ params: { "orgContact-id": id, "directoryObject-id": memberId },
796
+ query: { $select: select.join(",") },
797
+ scopes,
798
+ }));
799
+ const { row, truncated } = navSingleResult(raw, fields, full, "member-of");
800
+ if (truncated)
801
+ return { memberOf: row, help: [fullHint("entra contact show-member-of", flags, profileName)] };
802
+ return { memberOf: row };
803
+ }
804
+ // The $count routes return a text/plain integer scalar rather than a JSON
805
+ // collection, so the leaf reads it through session.execute and accepts only
806
+ // a non-negative integer. The scalars carry no operation-level
807
+ // documentation page and follow the parent list contract (ConsistencyLevel
808
+ // eventual). There is no --select/--limit contract on the count: strict
809
+ // input validation refuses those flags before credentials.
810
+ export async function countContactMemberOf(session, flags, profile, operation, help, profileName) {
811
+ void profileName;
812
+ checkReadOperation(operation, help);
813
+ const scopes = membershipScopesFor(flags, profile, help);
814
+ const id = contactId(flags, help);
815
+ const raw = await withGuidance(membershipDenialHints(flags), () => session.execute({
816
+ profile, operation, params: { "orgContact-id": id }, scopes, consistencyLevel: "eventual", scalar: true,
817
+ }));
818
+ if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 0) {
819
+ throw new AxiError("Graph returned a malformed member-of count body", "GRAPH_ERROR", [
820
+ "Member-of counts carry one non-negative integer scalar; treat anything else as unknown, not empty",
821
+ ]);
822
+ }
823
+ const count = { returned: raw, complete: true };
824
+ return raw === 0
825
+ ? { count, help: ["0 memberships returned; a contact outside every group and administrative unit carries none, so the absence of rows is the answer, not an error"] }
826
+ : { count };
827
+ }