@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,461 @@
1
+ import { AxiError } from "axi-sdk-js";
2
+ // EXT-01 deleted-items subfamily: the read mapping behind
3
+ // `mg-axi entra deleted-user|deleted-group|deleted-application|
4
+ // deleted-service-principal|deleted-administrative-unit list/count` and
5
+ // `mg-axi entra deleted-item show`. Operation construction stays beside its
6
+ // command; the shared session owns URLs, credentials, paging, retries and
7
+ // error translation, and the SDK owns TOON rendering. This module only maps
8
+ // flags to session calls and projects rows for compact output.
9
+ //
10
+ // Reviewed against the v1.0 directory-deleteditems-list and
11
+ // directory-deleteditems-get operation documentation on 2026-10-05. The list
12
+ // page documents per-type least-privilege permissions (delegated and
13
+ // application share the same scope per type; personal Microsoft accounts are
14
+ // not supported): user takes User.Read.All, group takes Group.Read.All,
15
+ // application and servicePrincipal take Application.Read.All, and
16
+ // administrativeUnit takes AdministrativeUnit.Read.All. No delegated role
17
+ // and no P1/P2 prerequisite are stated for these reads. The OData cast is a
18
+ // required part of the list URI, so untyped list/count commands do not exist
19
+ // and device casts carry no documented v1.0 permission contract and stay
20
+ // out. The $count scalar follows its typed list's permission contract.
21
+ // Deleted users are personal data and deleted applications carry credential
22
+ // metadata, so defaults stay minimal and key/password credential collections
23
+ // are never selectable: GET never returns secret values and success
24
+ // redaction still applies. Restore, permanent delete, the POST
25
+ // lookup/validation actions and beta stay out. No mutation lives here.
26
+ //
27
+ // Show reads the single documented untyped get
28
+ // (GET /directory/deletedItems/{directoryObject-id}), which can return any
29
+ // deletable type. Its $select is therefore limited to the cross-type safe
30
+ // properties valid on every deletable type; per-type detail comes from the
31
+ // typed lists. The object's type is unknown before the read, so show takes
32
+ // no default scope: delegated callers pass the scope matching the object's
33
+ // type as --scopes. The @odata.type discriminator rides along automatically
34
+ // (envelope metadata, never selectable) so rows still name their kind.
35
+ // Every deleted-user property this slice may request or display. Phones,
36
+ // photos and other PII beyond what identifies the restore target stay out;
37
+ // anything else fails before credentials.
38
+ export const KNOWN_DELETED_USER_FIELDS = [
39
+ "id",
40
+ "displayName",
41
+ "userPrincipalName",
42
+ "mail",
43
+ "userType",
44
+ "deletedDateTime",
45
+ ];
46
+ // Every deleted-group property this slice may request or display. Soft
47
+ // deleted security groups report securityEnabled false through a known
48
+ // upstream limitation, so groupTypes names the real kind.
49
+ export const KNOWN_DELETED_GROUP_FIELDS = [
50
+ "id",
51
+ "displayName",
52
+ "mail",
53
+ "mailNickname",
54
+ "groupTypes",
55
+ "visibility",
56
+ "securityEnabled",
57
+ "deletedDateTime",
58
+ ];
59
+ // Every deleted-application property this slice may request or display.
60
+ // appId (client ID) is distinct from the object id. keyCredentials and
61
+ // passwordCredentials are never selectable: credential metadata stays
62
+ // minimal and secret values can never appear.
63
+ export const KNOWN_DELETED_APPLICATION_FIELDS = [
64
+ "id",
65
+ "appId",
66
+ "displayName",
67
+ "publisherDomain",
68
+ "signInAudience",
69
+ "deletedDateTime",
70
+ ];
71
+ // Every deleted-service-principal property this slice may request or
72
+ // display. appId (client ID) is distinct from the object id; secret-bearing
73
+ // fields are never selectable.
74
+ export const KNOWN_DELETED_SERVICE_PRINCIPAL_FIELDS = [
75
+ "id",
76
+ "appId",
77
+ "displayName",
78
+ "servicePrincipalType",
79
+ "accountEnabled",
80
+ "deletedDateTime",
81
+ ];
82
+ // Every deleted-administrative-unit property this slice may request or
83
+ // display.
84
+ export const KNOWN_DELETED_ADMINISTRATIVE_UNIT_FIELDS = [
85
+ "id",
86
+ "displayName",
87
+ "description",
88
+ "visibility",
89
+ "membershipType",
90
+ "deletedDateTime",
91
+ ];
92
+ // Every deleted-item show property this slice may request or display: the
93
+ // cross-type safe intersection, valid on whatever deletable type the
94
+ // untyped get returns. Per-type detail comes from the typed lists.
95
+ export const KNOWN_DELETED_SHOW_FIELDS = [
96
+ "id",
97
+ "displayName",
98
+ "deletedDateTime",
99
+ ];
100
+ // The subtype discriminator rides on every row without being selectable: it
101
+ // names the concrete kind (user, group, application and the rest).
102
+ const DELETED_TYPE_PROPERTY = "@odata.type";
103
+ // Compact list rows carry the identifier, the display name and the deletion
104
+ // timestamp; applications and service principals additionally carry appId so
105
+ // the client ID is never conflated with the object id.
106
+ export const DEFAULT_DELETED_USER_LIST_SELECT = ["id", "displayName", "deletedDateTime"];
107
+ export const DEFAULT_DELETED_GROUP_LIST_SELECT = ["id", "displayName", "deletedDateTime"];
108
+ export const DEFAULT_DELETED_APPLICATION_LIST_SELECT = ["id", "appId", "displayName", "deletedDateTime"];
109
+ export const DEFAULT_DELETED_SERVICE_PRINCIPAL_LIST_SELECT = ["id", "appId", "displayName", "deletedDateTime"];
110
+ export const DEFAULT_DELETED_ADMINISTRATIVE_UNIT_LIST_SELECT = ["id", "displayName", "deletedDateTime"];
111
+ // Show rows carry the full cross-type safe set.
112
+ const DEFAULT_SHOW_SELECT = [...KNOWN_DELETED_SHOW_FIELDS];
113
+ // Delegated list/count defaults are per-type least privilege; application
114
+ // profiles use their configured .default audience and reject --scopes. All
115
+ // four scopes are already in the shared READ_SCOPES allowlist, and every
116
+ // one is a read scope, so no new scope and no deferral are needed here.
117
+ export const DELETED_ITEM_SCOPES = {
118
+ user: ["https://graph.microsoft.com/User.Read.All"],
119
+ group: ["https://graph.microsoft.com/Group.Read.All"],
120
+ application: ["https://graph.microsoft.com/Application.Read.All"],
121
+ "service-principal": ["https://graph.microsoft.com/Application.Read.All"],
122
+ "administrative-unit": ["https://graph.microsoft.com/AdministrativeUnit.Read.All"],
123
+ };
124
+ // Every scope a deleted-item show caller may need to pass, one per deletable
125
+ // type. Show takes no default because the object's type is unknown before
126
+ // the read.
127
+ export const DELETED_SHOW_SCOPE_CHOICES = [
128
+ "https://graph.microsoft.com/User.Read.All",
129
+ "https://graph.microsoft.com/Group.Read.All",
130
+ "https://graph.microsoft.com/Application.Read.All",
131
+ "https://graph.microsoft.com/AdministrativeUnit.Read.All",
132
+ ];
133
+ const TRUNCATE_AT = 500;
134
+ const TYPE_FIELDS = {
135
+ user: KNOWN_DELETED_USER_FIELDS,
136
+ group: KNOWN_DELETED_GROUP_FIELDS,
137
+ application: KNOWN_DELETED_APPLICATION_FIELDS,
138
+ "service-principal": KNOWN_DELETED_SERVICE_PRINCIPAL_FIELDS,
139
+ "administrative-unit": KNOWN_DELETED_ADMINISTRATIVE_UNIT_FIELDS,
140
+ };
141
+ const TYPE_LIST_DEFAULTS = {
142
+ user: DEFAULT_DELETED_USER_LIST_SELECT,
143
+ group: DEFAULT_DELETED_GROUP_LIST_SELECT,
144
+ application: DEFAULT_DELETED_APPLICATION_LIST_SELECT,
145
+ "service-principal": DEFAULT_DELETED_SERVICE_PRINCIPAL_LIST_SELECT,
146
+ "administrative-unit": DEFAULT_DELETED_ADMINISTRATIVE_UNIT_LIST_SELECT,
147
+ };
148
+ // The only operations this slice ever binds. Anything else - the untyped
149
+ // list/count (upstream requires the cast), device casts (no documented v1.0
150
+ // permission contract), typed-cast singles (covered by the untyped show),
151
+ // POST lookup/validation actions, restore, permanent delete, beta - is
152
+ // refused before credentials.
153
+ const LIST_ROUTES = {
154
+ "GET:/directory/deletedItems/graph.user": "user",
155
+ "GET:/directory/deletedItems/graph.group": "group",
156
+ "GET:/directory/deletedItems/graph.application": "application",
157
+ "GET:/directory/deletedItems/graph.servicePrincipal": "service-principal",
158
+ "GET:/directory/deletedItems/graph.administrativeUnit": "administrative-unit",
159
+ };
160
+ const COUNT_ROUTES = {
161
+ "GET:/directory/deletedItems/graph.user/$count": "user",
162
+ "GET:/directory/deletedItems/graph.group/$count": "group",
163
+ "GET:/directory/deletedItems/graph.application/$count": "application",
164
+ "GET:/directory/deletedItems/graph.servicePrincipal/$count": "service-principal",
165
+ "GET:/directory/deletedItems/graph.administrativeUnit/$count": "administrative-unit",
166
+ };
167
+ const SHOW_ROUTE = "GET:/directory/deletedItems/{directoryObject-id}";
168
+ function routeOf(operation) {
169
+ return `${operation.method}:${operation.path}`;
170
+ }
171
+ function fieldList(raw, known, flag, help) {
172
+ const knownSet = new Set(known);
173
+ const parts = String(raw)
174
+ .split(",")
175
+ .map(part => part.trim())
176
+ .filter(part => part.length > 0);
177
+ if (!parts.length)
178
+ throw new AxiError(`--${flag} needs at least one property`, "VALIDATION_ERROR", [help]);
179
+ const fields = [];
180
+ for (const part of parts) {
181
+ if (!knownSet.has(part)) {
182
+ throw new AxiError(`Unknown deleted-item property ${part} in --${flag}`, "VALIDATION_ERROR", [
183
+ help,
184
+ `Known deleted-item properties: ${known.join(", ")}`,
185
+ ]);
186
+ }
187
+ if (!fields.includes(part))
188
+ fields.push(part);
189
+ }
190
+ return fields;
191
+ }
192
+ function listScopesFor(flags, type, profile, help) {
193
+ if (profile.mode === "application") {
194
+ if (flags.scopes !== undefined) {
195
+ throw new AxiError("Application profiles use the configured Graph .default audience; delegated scopes are unavailable", "VALIDATION_ERROR", [help, "mg-axi profile show --profile <name>"]);
196
+ }
197
+ return undefined;
198
+ }
199
+ if (flags.scopes === undefined)
200
+ return [...DELETED_ITEM_SCOPES[type]];
201
+ const scopes = String(flags.scopes)
202
+ .split(",")
203
+ .map(scope => scope.trim())
204
+ .filter(scope => scope.length > 0);
205
+ return scopes;
206
+ }
207
+ // Show takes no default scope: the untyped get can return any deletable
208
+ // type, so the caller passes the scope matching the object's type.
209
+ function showScopesFor(flags, profile, help) {
210
+ if (profile.mode === "application") {
211
+ if (flags.scopes !== undefined) {
212
+ throw new AxiError("Application profiles use the configured Graph .default audience; delegated scopes are unavailable", "VALIDATION_ERROR", [help, "mg-axi profile show --profile <name>"]);
213
+ }
214
+ return undefined;
215
+ }
216
+ if (flags.scopes === undefined) {
217
+ throw new AxiError("Deleted-item show needs an explicit --scopes matching the object's type", "VALIDATION_ERROR", [
218
+ help,
219
+ `Pass one of: ${DELETED_SHOW_SCOPE_CHOICES.join(", ")}`,
220
+ "List the recycle bin first: the @odata.type discriminator names each row's kind",
221
+ ]);
222
+ }
223
+ const scopes = String(flags.scopes)
224
+ .split(",")
225
+ .map(scope => scope.trim())
226
+ .filter(scope => scope.length > 0);
227
+ if (!scopes.length)
228
+ throw new AxiError("--scopes needs at least one scope", "VALIDATION_ERROR", [help]);
229
+ return scopes;
230
+ }
231
+ function truncateValue(value, full) {
232
+ const truncate = (text) => full || text.length <= TRUNCATE_AT
233
+ ? { value: text, truncated: false }
234
+ : { value: `${text.slice(0, TRUNCATE_AT)}... (truncated, ${text.length} chars total)`, truncated: true };
235
+ if (typeof value === "string")
236
+ return truncate(value);
237
+ if (Array.isArray(value) && value.every(entry => typeof entry === "string")) {
238
+ const projected = value.map(truncate);
239
+ return { value: projected.map(entry => entry.value), truncated: projected.some(entry => entry.truncated) };
240
+ }
241
+ return { value, truncated: false };
242
+ }
243
+ // Local projection preserves Graph's null/missing distinction: an explicit
244
+ // null stays null, an absent property stays absent and is never synthesized.
245
+ // The subtype discriminator rides along automatically when the server sent
246
+ // it; unreviewed properties never do, so secrets or key material any
247
+ // subtype carries stay out.
248
+ function project(row, fields, full) {
249
+ const source = row !== null && typeof row === "object" && !Array.isArray(row) ? row : {};
250
+ const projected = {};
251
+ let truncated = false;
252
+ for (const field of fields) {
253
+ if (!Object.hasOwn(source, field))
254
+ continue;
255
+ const result = truncateValue(source[field], full);
256
+ projected[field] = result.value;
257
+ truncated = truncated || result.truncated;
258
+ }
259
+ if (typeof source[DELETED_TYPE_PROPERTY] === "string")
260
+ projected[DELETED_TYPE_PROPERTY] = source[DELETED_TYPE_PROPERTY];
261
+ return { row: projected, truncated };
262
+ }
263
+ function selectedFields(flags, known, defaults, help) {
264
+ const select = flags.select === undefined ? [...defaults] : fieldList(flags.select, known, "select", help);
265
+ const fields = flags.fields === undefined ? [...select] : fieldList(flags.fields, known, "fields", help);
266
+ const missing = fields.find(field => !select.includes(field));
267
+ if (missing) {
268
+ throw new AxiError(`--fields ${missing} was not fetched; request it with --select`, "VALIDATION_ERROR", [help]);
269
+ }
270
+ return { select, fields };
271
+ }
272
+ function profileHint(profileName) {
273
+ return `--profile ${shellValue(profileName)}`;
274
+ }
275
+ function shellValue(value) {
276
+ return /^[A-Za-z0-9_.,:/@=-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
277
+ }
278
+ function fullHint(command, flags, profileName) {
279
+ const args = Object.entries({ ...flags, profile: profileName, full: true })
280
+ .map(([name, value]) => value === true ? `--${name}` : `--${name} ${shellValue(String(value))}`);
281
+ return `mg-axi ${command} ${args.join(" ")}`;
282
+ }
283
+ // Denials carry the session's generic grant/role/licence cause; each type
284
+ // adds the scope that actually unlocks it, because a 403 alone never says
285
+ // which prerequisite is missing. No delegated role and no P1/P2
286
+ // prerequisite are stated for these reads.
287
+ function withGuidance(hints, run) {
288
+ return run().catch(error => {
289
+ if (error instanceof AxiError && error.code === "GRAPH_ERROR" && /\(403\)/.test(error.message)) {
290
+ throw new AxiError(error.message, "GRAPH_ERROR", [...hints, ...error.suggestions]);
291
+ }
292
+ throw error;
293
+ });
294
+ }
295
+ function denialHints(type) {
296
+ const scope = DELETED_ITEM_SCOPES[type][0];
297
+ return [
298
+ `Deleted-${type} reads need ${scope} for delegated or application access; delegated callers pass it as --scopes`,
299
+ "Personal Microsoft accounts are not supported for deleted-item reads",
300
+ "No delegated role or P1/P2 prerequisite is stated for deleted-item reads; never diagnose role or licence solely from HTTP 403",
301
+ ];
302
+ }
303
+ const SHOW_DENIAL_HINTS = [
304
+ "Deleted-item show needs the scope matching the object's type (User.Read.All, Group.Read.All, Application.Read.All or AdministrativeUnit.Read.All): delegated callers pass it as --scopes, application access needs the admin-consented scope on the configured .default audience",
305
+ "Personal Microsoft accounts are not supported for deleted-item reads",
306
+ "No delegated role or P1/P2 prerequisite is stated for this read; never diagnose role or licence solely from HTTP 403",
307
+ ];
308
+ const SHOW_SCOPE_NOTE = "Show takes no default scope: pass the scope matching the object's type as --scopes; list the recycle bin first and read each row's @odata.type kind";
309
+ function typeCommand(type, verb) {
310
+ return `mg-axi entra deleted-${type} ${verb}`;
311
+ }
312
+ function showCommand(profileName) {
313
+ return `mg-axi entra deleted-item show --id <object-id> ${profileHint(profileName)}`;
314
+ }
315
+ // Restoring the saved select keeps cursor resumes lossless when --select is
316
+ // omitted. The typed lists document no reviewed $filter/$orderby contract
317
+ // here, so the catalogue declares no such flags and strict input validation
318
+ // refuses them before credentials instead of forwarding an unreviewed query.
319
+ function collectionCommon(session, flags, type, operation, help, profile) {
320
+ const cursor = flags.cursor === undefined ? undefined : String(flags.cursor);
321
+ if (cursor !== undefined && !cursor.trim())
322
+ throw new AxiError("--cursor needs the opaque cursor from a partial result", "VALIDATION_ERROR", [help]);
323
+ const saved = cursor === undefined ? undefined : session.cursorQuery(operation, cursor);
324
+ const known = TYPE_FIELDS[type];
325
+ const defaults = TYPE_LIST_DEFAULTS[type];
326
+ const { select, fields } = selectedFields(flags, known, saved?.["$select"] === undefined ? [...defaults] : fieldList(saved["$select"], known, "select", help), help);
327
+ return { cursor, select, fields, scopes: listScopesFor(flags, type, profile, help), full: flags.full === true };
328
+ }
329
+ function collectArgs(profile, operation, common, flags, help) {
330
+ const query = { $select: common.select.join(",") };
331
+ const args = { profile, operation, query, scopes: common.scopes };
332
+ if (common.cursor !== undefined)
333
+ args.cursor = common.cursor;
334
+ if (flags.all === true) {
335
+ if (flags.limit !== undefined)
336
+ throw new AxiError("--limit and --all cannot be combined", "VALIDATION_ERROR", [help]);
337
+ }
338
+ else {
339
+ args.limit = flags.limit === undefined ? 100 : Number(flags.limit);
340
+ }
341
+ return args;
342
+ }
343
+ function resumeHint(profileName) {
344
+ return `Resume losslessly with the same flags plus --cursor <cursor-from-output> ${profileHint(profileName)}`;
345
+ }
346
+ function deletedId(flags, help) {
347
+ const id = String(flags.id);
348
+ if (!id.trim())
349
+ throw new AxiError("--id needs the deleted object ID", "VALIDATION_ERROR", [help]);
350
+ return id;
351
+ }
352
+ function listType(operation, help) {
353
+ const type = LIST_ROUTES[routeOf(operation)];
354
+ if (operation.method !== "GET" || type === undefined) {
355
+ throw new AxiError(`Refused non-read route ${operation.id}: deleted-item lists serve only the five catalogued v1.0 typed deletedItems GETs`, "VALIDATION_ERROR", [
356
+ help,
357
+ "Untyped lists need the OData cast upstream requires; device casts, POST lookups, restore and beta need their own review",
358
+ ]);
359
+ }
360
+ return type;
361
+ }
362
+ function countType(operation, help) {
363
+ const type = COUNT_ROUTES[routeOf(operation)];
364
+ if (operation.method !== "GET" || type === undefined) {
365
+ throw new AxiError(`Refused non-read route ${operation.id}: deleted-item counts serve only the five catalogued v1.0 typed deletedItems $count GETs`, "VALIDATION_ERROR", [
366
+ help,
367
+ "Untyped counts need the OData cast upstream requires; device casts and beta need their own review",
368
+ ]);
369
+ }
370
+ return type;
371
+ }
372
+ function checkShowOperation(operation, help) {
373
+ if (operation.method !== "GET" || routeOf(operation) !== SHOW_ROUTE) {
374
+ throw new AxiError(`Refused non-read route ${operation.id}: deleted-item show serves only the catalogued v1.0 untyped deletedItems get`, "VALIDATION_ERROR", [
375
+ help,
376
+ "Typed-cast singles are covered by this same untyped get; restore, permanent delete and beta need their own review",
377
+ ]);
378
+ }
379
+ }
380
+ export async function listDeletedItems(session, flags, profile, operation, help, profileName) {
381
+ const type = listType(operation, help);
382
+ const common = collectionCommon(session, flags, type, operation, help, profile);
383
+ const result = await withGuidance(denialHints(type), () => session.collect(collectArgs(profile, operation, common, flags, help)));
384
+ const effectiveFlags = { ...flags, select: result.query.$select ?? TYPE_LIST_DEFAULTS[type].join(",") };
385
+ const rows = [];
386
+ let truncated = false;
387
+ for (const row of result.value) {
388
+ const projected = project(row, common.fields, common.full);
389
+ rows.push(projected.row);
390
+ truncated = truncated || projected.truncated;
391
+ }
392
+ const command = typeCommand(type, "list");
393
+ const showHint = showCommand(profileName);
394
+ const groupNote = type === "group"
395
+ ? ["Soft-deleted security groups report securityEnabled false through a known upstream limitation; read groupTypes to name the real kind"]
396
+ : [];
397
+ const appNote = type === "application" || type === "service-principal"
398
+ ? ["appId (client ID) is distinct from the object id; credential collections are never selectable and GET never returns secret values"]
399
+ : [];
400
+ const emptyNote = `0 deleted ${type === "service-principal" ? "service principals" : `${type}s`} matched; the absence of results is the answer, not an error`;
401
+ const truncationHints = truncated ? [fullHint(command, effectiveFlags, profileName)] : [];
402
+ if (!result.complete) {
403
+ return {
404
+ deletedItems: rows,
405
+ count: { returned: rows.length, complete: false, reason: result.reason },
406
+ cursor: result.cursor,
407
+ help: [...truncationHints, resumeHint(profileName), showHint, ...groupNote, ...appNote],
408
+ };
409
+ }
410
+ const count = { returned: rows.length, complete: true };
411
+ if (!rows.length) {
412
+ return { deletedItems: rows, count, help: [emptyNote, ...groupNote, ...appNote] };
413
+ }
414
+ return { deletedItems: rows, count, help: [...truncationHints, showHint, ...groupNote, ...appNote] };
415
+ }
416
+ export async function showDeletedItem(session, flags, profile, operation, help, profileName) {
417
+ checkShowOperation(operation, help);
418
+ const { select, fields } = selectedFields(flags, KNOWN_DELETED_SHOW_FIELDS, DEFAULT_SHOW_SELECT, help);
419
+ const scopes = showScopesFor(flags, profile, help);
420
+ const full = flags.full === true;
421
+ const id = deletedId(flags, help);
422
+ const raw = await withGuidance(SHOW_DENIAL_HINTS, () => session.execute({
423
+ profile,
424
+ operation,
425
+ params: { "directoryObject-id": id },
426
+ query: { $select: select.join(",") },
427
+ scopes,
428
+ }));
429
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
430
+ throw new AxiError("Graph returned a malformed deleted-item body", "GRAPH_ERROR", [
431
+ "Single-object reads carry one directoryObject; treat anything else as unknown, not empty",
432
+ ]);
433
+ }
434
+ const { row, truncated } = project(raw, fields, full);
435
+ const helpHints = [...(truncated ? [fullHint("entra deleted-item show", flags, profileName)] : []), SHOW_SCOPE_NOTE];
436
+ if (typeof row["@odata.type"] === "string" && row["@odata.type"].includes("group")) {
437
+ helpHints.push("Soft-deleted security groups report securityEnabled false through a known upstream limitation; read groupTypes to name the real kind");
438
+ }
439
+ return { deletedItem: row, help: helpHints };
440
+ }
441
+ // The $count route returns a text/plain integer scalar rather than a JSON
442
+ // collection, so the leaf reads it through session.execute and accepts only
443
+ // a non-negative integer. There is no --filter/--select/--limit contract on
444
+ // the count: the catalogue declares no such flags and strict input
445
+ // validation refuses them before credentials.
446
+ export async function countDeletedItems(session, flags, profile, operation, help, profileName) {
447
+ void profileName;
448
+ const type = countType(operation, help);
449
+ const scopes = listScopesFor(flags, type, profile, help);
450
+ const raw = await withGuidance(denialHints(type), () => session.execute({ profile, operation, scopes, scalar: true }));
451
+ if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 0) {
452
+ throw new AxiError("Graph returned a malformed deleted-item count body", "GRAPH_ERROR", [
453
+ "Deleted-item counts carry one non-negative integer scalar; treat anything else as unknown, not empty",
454
+ ]);
455
+ }
456
+ const label = type === "service-principal" ? "service principals" : `${type}s`;
457
+ const count = { returned: raw, complete: true };
458
+ return raw === 0
459
+ ? { count, help: [`0 deleted ${label} in the recycle bin; the absence of results is the answer, not an error`] }
460
+ : { count };
461
+ }