@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,252 @@
1
+ import { AxiError } from "axi-sdk-js";
2
+ // EXT-04 partner contracts subfamily: the read mapping behind `mg-axi entra
3
+ // contract list/show/count`. Operation construction stays beside its command;
4
+ // the shared session owns URLs, credentials, paging, retries and error
5
+ // translation, and the SDK owns TOON rendering. This module only maps flags
6
+ // to session calls and projects rows for compact output.
7
+ //
8
+ // Reviewed against the v1.0 contract-list, contract-get and contract
9
+ // resource documentation on 2026-10-04. Every read takes D/A
10
+ // Directory.Read.All; delegated callers additionally need a supported Entra
11
+ // role (Directory Readers is the only least-privileged role supported for
12
+ // these operations). Personal Microsoft accounts are not supported. No
13
+ // P1/P2 prerequisite is stated for these reads. Contracts exist in partner
14
+ // tenants only (Cloud Solution Provider, Office 365 Syndication or Advisor
15
+ // programs); a non-partner tenant lists zero contracts, which is an answer
16
+ // rather than an error. The list documents OData query parameters with
17
+ // filtering for customerId, defaultDomainName and displayName, so --filter
18
+ // passes through as plain $filter with no $count or ConsistencyLevel
19
+ // contract. The $count route returns a text/plain integer scalar; the leaf
20
+ // reads it through session.execute and accepts only a non-negative integer,
21
+ // so no --filter/--select/--limit/--cursor contract exists on the count.
22
+ // contracts/delta() stays out: delta-token sync needs its own paging and
23
+ // change-tracking contract. Beta contracts and the POST lookup actions
24
+ // belong to later EXT-04 subfamilies. No contract mutation exists in this
25
+ // slice.
26
+ // Every contract property this slice may request or display, matching the
27
+ // reviewed resource. Anything else fails before credentials.
28
+ export const KNOWN_CONTRACT_FIELDS = [
29
+ "contractType",
30
+ "customerId",
31
+ "defaultDomainName",
32
+ "displayName",
33
+ "id",
34
+ ];
35
+ const KNOWN_CONTRACTS = new Set(KNOWN_CONTRACT_FIELDS);
36
+ // Compact contract rows: the partnership id, the customer name and domain
37
+ // copies that name the customer, and the contract kind that decides the
38
+ // partner relationship.
39
+ const DEFAULT_CONTRACT_LIST_SELECT = ["id", "displayName", "contractType", "defaultDomainName"];
40
+ // Show rows: the full reviewed contract set.
41
+ const DEFAULT_CONTRACT_SHOW_SELECT = [...KNOWN_CONTRACT_FIELDS];
42
+ // Delegated defaults are operation-specific; application profiles use their
43
+ // configured .default audience and reject --scopes.
44
+ export const DEFAULT_CONTRACT_SCOPES = ["https://graph.microsoft.com/Directory.Read.All"];
45
+ const TRUNCATE_AT = 500;
46
+ function fieldList(raw, known, knownList, flag, help) {
47
+ const parts = String(raw)
48
+ .split(",")
49
+ .map(part => part.trim())
50
+ .filter(part => part.length > 0);
51
+ if (!parts.length)
52
+ throw new AxiError(`--${flag} needs at least one property`, "VALIDATION_ERROR", [help]);
53
+ const fields = [];
54
+ for (const part of parts) {
55
+ if (!known.has(part)) {
56
+ throw new AxiError(`Unknown contract property ${part} in --${flag}`, "VALIDATION_ERROR", [
57
+ help,
58
+ `Known properties: ${knownList.join(", ")}`,
59
+ ]);
60
+ }
61
+ if (!fields.includes(part))
62
+ fields.push(part);
63
+ }
64
+ return fields;
65
+ }
66
+ function scopesFor(flags, defaults, profile, help) {
67
+ if (profile.mode === "application") {
68
+ if (flags.scopes !== undefined) {
69
+ throw new AxiError("Application profiles use the configured Graph .default audience; delegated scopes are unavailable", "VALIDATION_ERROR", [help, "mg-axi profile show --profile <name>"]);
70
+ }
71
+ return undefined;
72
+ }
73
+ if (flags.scopes === undefined)
74
+ return [...defaults];
75
+ const scopes = String(flags.scopes)
76
+ .split(",")
77
+ .map(scope => scope.trim())
78
+ .filter(scope => scope.length > 0);
79
+ return scopes;
80
+ }
81
+ function truncateValue(value, full) {
82
+ const truncate = (text) => full || text.length <= TRUNCATE_AT
83
+ ? { value: text, truncated: false }
84
+ : { value: `${text.slice(0, TRUNCATE_AT)}... (truncated, ${text.length} chars total)`, truncated: true };
85
+ if (typeof value === "string")
86
+ return truncate(value);
87
+ if (Array.isArray(value) && value.every(entry => typeof entry === "string")) {
88
+ const projected = value.map(truncate);
89
+ return { value: projected.map(entry => entry.value), truncated: projected.some(entry => entry.truncated) };
90
+ }
91
+ return { value, truncated: false };
92
+ }
93
+ // Local projection preserves Graph's null/missing distinction: an explicit
94
+ // null stays null, an absent property stays absent and is never synthesized.
95
+ function project(row, fields, full) {
96
+ const source = row !== null && typeof row === "object" && !Array.isArray(row) ? row : {};
97
+ const projected = {};
98
+ let truncated = false;
99
+ for (const field of fields) {
100
+ if (!Object.hasOwn(source, field))
101
+ continue;
102
+ const result = truncateValue(source[field], full);
103
+ projected[field] = result.value;
104
+ truncated = truncated || result.truncated;
105
+ }
106
+ return { row: projected, truncated };
107
+ }
108
+ function selectedFields(flags, known, knownList, defaults, help) {
109
+ const select = flags.select === undefined ? [...defaults] : fieldList(flags.select, known, knownList, "select", help);
110
+ const fields = flags.fields === undefined ? [...select] : fieldList(flags.fields, known, knownList, "fields", help);
111
+ const missing = fields.find(field => !select.includes(field));
112
+ if (missing) {
113
+ throw new AxiError(`--fields ${missing} was not fetched; request it with --select`, "VALIDATION_ERROR", [help]);
114
+ }
115
+ return { select, fields };
116
+ }
117
+ function profileHint(profileName) {
118
+ return `--profile ${shellValue(profileName)}`;
119
+ }
120
+ function shellValue(value) {
121
+ return /^[A-Za-z0-9_.,:/@=-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
122
+ }
123
+ function fullHint(command, flags, profileName) {
124
+ const args = Object.entries({ ...flags, profile: profileName, full: true })
125
+ .map(([name, value]) => value === true ? `--${name}` : `--${name} ${shellValue(String(value))}`);
126
+ return `mg-axi ${command} ${args.join(" ")}`;
127
+ }
128
+ // Denials carry the session's generic grant/role/licence cause; each
129
+ // operation adds the scope, roles and partner prerequisite that actually
130
+ // unlock it, because a 403 alone never says which prerequisite is missing.
131
+ function withGuidance(hints, run) {
132
+ return run().catch(error => {
133
+ if (error instanceof AxiError && error.code === "GRAPH_ERROR" && /\(403\)/.test(error.message)) {
134
+ throw new AxiError(error.message, "GRAPH_ERROR", [...hints, ...error.suggestions]);
135
+ }
136
+ throw error;
137
+ });
138
+ }
139
+ const CONTRACT_DENIAL_HINTS = [
140
+ "Contract reads need Directory.Read.All plus a supported Entra role for delegated access (Directory Readers is the least-privileged role), or admin-consented Directory.Read.All for application access",
141
+ "Contracts exist in partner tenants only (Cloud Solution Provider, Office 365 Syndication or Advisor programs); a non-partner tenant reads empty, which is an answer rather than an error",
142
+ "Personal Microsoft accounts are not supported for contract reads",
143
+ "No P1/P2 prerequisite is stated for contract reads; never diagnose licence solely from HTTP 403",
144
+ ];
145
+ const PARTNER_NOTE = "Contracts exist in partner tenants only; a non-partner tenant lists zero contracts, which is an answer rather than an error";
146
+ // Restoring the saved select/filter keeps cursor resumes lossless when
147
+ // --select or --filter is omitted. The contract list passes --filter through
148
+ // as plain $filter with no $count or ConsistencyLevel contract.
149
+ function collectionCommon(session, flags, known, knownList, defaults, defaultScopes, operation, help, profile) {
150
+ const cursor = flags.cursor === undefined ? undefined : String(flags.cursor);
151
+ if (cursor !== undefined && !cursor.trim())
152
+ throw new AxiError("--cursor needs the opaque cursor from a partial result", "VALIDATION_ERROR", [help]);
153
+ const saved = cursor === undefined ? undefined : session.cursorQuery(operation, cursor);
154
+ const { select, fields } = selectedFields(flags, known, knownList, saved?.["$select"] === undefined ? defaults : fieldList(saved["$select"], known, knownList, "select", help), help);
155
+ const filter = flags.filter === undefined ? saved?.["$filter"] : String(flags.filter);
156
+ return { cursor, select, fields, scopes: scopesFor(flags, defaultScopes, profile, help), full: flags.full === true, filter };
157
+ }
158
+ function collectArgs(profile, operation, common, flags, help) {
159
+ const query = { $select: common.select.join(",") };
160
+ if (common.filter !== undefined)
161
+ query.$filter = common.filter;
162
+ const args = { profile, operation, query, scopes: common.scopes };
163
+ if (common.cursor !== undefined)
164
+ args.cursor = common.cursor;
165
+ if (flags.all === true) {
166
+ if (flags.limit !== undefined)
167
+ throw new AxiError("--limit and --all cannot be combined", "VALIDATION_ERROR", [help]);
168
+ }
169
+ else {
170
+ args.limit = flags.limit === undefined ? 100 : Number(flags.limit);
171
+ }
172
+ return args;
173
+ }
174
+ function resumeHint(profileName) {
175
+ return `Resume losslessly with the same flags plus --cursor <cursor-from-output> ${profileHint(profileName)}`;
176
+ }
177
+ function contractId(flags, help) {
178
+ const id = String(flags.id);
179
+ if (!id.trim())
180
+ throw new AxiError("--id needs the contract partnership identifier", "VALIDATION_ERROR", [help]);
181
+ return id;
182
+ }
183
+ export async function listContracts(session, flags, profile, operation, help, profileName) {
184
+ const common = collectionCommon(session, flags, KNOWN_CONTRACTS, KNOWN_CONTRACT_FIELDS, DEFAULT_CONTRACT_LIST_SELECT, DEFAULT_CONTRACT_SCOPES, operation, help, profile);
185
+ const result = await withGuidance(CONTRACT_DENIAL_HINTS, () => session.collect(collectArgs(profile, operation, common, flags, help)));
186
+ const effectiveFlags = { ...flags, select: result.query.$select ?? DEFAULT_CONTRACT_LIST_SELECT.join(",") };
187
+ if (result.query.$filter !== undefined)
188
+ effectiveFlags.filter = result.query.$filter;
189
+ const contracts = [];
190
+ let truncated = false;
191
+ for (const row of result.value) {
192
+ const projected = project(row, common.fields, common.full);
193
+ contracts.push(projected.row);
194
+ truncated = truncated || projected.truncated;
195
+ }
196
+ const showHint = `mg-axi entra contract show --id <contract-id> ${profileHint(profileName)}`;
197
+ const truncationHints = truncated ? [fullHint("entra contract list", effectiveFlags, profileName)] : [];
198
+ if (!result.complete) {
199
+ return {
200
+ contracts,
201
+ count: { returned: contracts.length, complete: false, reason: result.reason },
202
+ cursor: result.cursor,
203
+ help: [...truncationHints, resumeHint(profileName), showHint, PARTNER_NOTE],
204
+ };
205
+ }
206
+ const count = { returned: contracts.length, complete: true };
207
+ if (!contracts.length) {
208
+ return {
209
+ contracts,
210
+ count,
211
+ help: ["0 contracts matched; the absence of results is the answer, not an error", PARTNER_NOTE],
212
+ };
213
+ }
214
+ return { contracts, count, help: [...truncationHints, showHint, PARTNER_NOTE] };
215
+ }
216
+ export async function showContract(session, flags, profile, operation, help, profileName) {
217
+ const { select, fields } = selectedFields(flags, KNOWN_CONTRACTS, KNOWN_CONTRACT_FIELDS, DEFAULT_CONTRACT_SHOW_SELECT, help);
218
+ const scopes = scopesFor(flags, DEFAULT_CONTRACT_SCOPES, profile, help);
219
+ const full = flags.full === true;
220
+ const raw = await withGuidance(CONTRACT_DENIAL_HINTS, () => session.execute({
221
+ profile,
222
+ operation,
223
+ params: { "contract-id": contractId(flags, help) },
224
+ query: { $select: select.join(",") },
225
+ scopes,
226
+ }));
227
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
228
+ throw new AxiError("Graph returned a malformed contract body", "GRAPH_ERROR", [
229
+ "Single-contract reads carry one contract object; treat anything else as unknown, not empty",
230
+ ]);
231
+ }
232
+ const { row, truncated } = project(raw, fields, full);
233
+ if (truncated)
234
+ return { contract: row, help: [fullHint("entra contract show", flags, profileName)] };
235
+ return { contract: row };
236
+ }
237
+ // The $count route returns a text/plain integer scalar rather than a JSON
238
+ // collection, so the leaf reads it through session.execute and accepts only
239
+ // a non-negative integer. There is no --filter/--select/--limit contract on
240
+ // the count: the catalogue declares no such flags and strict input
241
+ // validation refuses them before credentials.
242
+ export async function countContracts(session, flags, profile, operation, help, profileName) {
243
+ void profileName;
244
+ const scopes = scopesFor(flags, DEFAULT_CONTRACT_SCOPES, profile, help);
245
+ const raw = await withGuidance(CONTRACT_DENIAL_HINTS, () => session.execute({ profile, operation, scopes }));
246
+ if (typeof raw !== "number" || !Number.isInteger(raw) || raw < 0) {
247
+ throw new AxiError("Graph returned a malformed contract count body", "GRAPH_ERROR", [
248
+ "Contract counts carry one non-negative integer scalar; treat anything else as unknown, not empty",
249
+ ]);
250
+ }
251
+ return { contractCount: raw };
252
+ }