@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,42 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { ConfidentialClientApplication } from "@azure/msal-node";
3
+ import { graphAudience } from "./app-auth.js";
4
+ // Only this adapter touches application credentials; keys and assertions are referenced, never logged.
5
+ export class MsalApplicationProvider {
6
+ storage = "session-only";
7
+ async acquire(profile, scopes) {
8
+ const configuration = {
9
+ auth: { clientId: profile.clientId, authority: `https://login.microsoftonline.com/${profile.tenantId}`,
10
+ ...(profile.credentialRef.provider === "certificate"
11
+ ? { clientCertificate: { thumbprint: profile.credentialRef.thumbprint, privateKey: await this.privateKey(profile.credentialRef.key) } }
12
+ : { clientAssertion: () => this.assertion() }) },
13
+ system: { loggerOptions: { loggerCallback: () => { }, piiLoggingEnabled: false } },
14
+ };
15
+ const result = await new ConfidentialClientApplication(configuration)
16
+ .acquireTokenByClientCredential({ scopes: scopes.length ? scopes : [graphAudience(profile)] });
17
+ if (!result)
18
+ throw new Error("Empty application token response");
19
+ if (result.authority.toLowerCase() !== `${configuration.auth.authority.toLowerCase()}/`)
20
+ throw new Error("Configured application authority mismatch");
21
+ return { token: result.accessToken, expiresAt: result.expiresOn?.getTime() ?? NaN, tenantId: profile.tenantId, clientId: profile.clientId };
22
+ }
23
+ async privateKey(key) {
24
+ const store = await import("keytar").then(module => module.default).catch(() => undefined);
25
+ this.storage = store ? "os-protected" : "session-only";
26
+ const material = await store?.getPassword("mg-axi", key).catch(() => null);
27
+ if (typeof material !== "string" || !material.trim())
28
+ throw new Error("Missing referenced certificate private key");
29
+ return material;
30
+ }
31
+ async assertion() {
32
+ this.storage = "session-only";
33
+ const file = process.env.AZURE_FEDERATED_TOKEN_FILE;
34
+ if (!file?.trim())
35
+ throw new Error("Missing federated token source");
36
+ // Read on every invocation: projected federated tokens rotate on disk.
37
+ const assertion = readFileSync(file, "utf8").trim();
38
+ if (!assertion)
39
+ throw new Error("Missing federated token source");
40
+ return assertion;
41
+ }
42
+ }
@@ -0,0 +1,82 @@
1
+ import { PublicClientApplication } from "@azure/msal-node";
2
+ import open from "open";
3
+ // Only this adapter touches Microsoft authentication and OS credential storage.
4
+ export class MsalProvider {
5
+ storage = "session-only";
6
+ sessions = new Map();
7
+ async application(profile, fresh = false) {
8
+ const key = JSON.stringify([profile.credentialRef.key, profile.tenantId, profile.clientId, profile.cloud]);
9
+ const existing = this.sessions.get(key);
10
+ if (existing && !fresh) {
11
+ this.storage = existing.store ? "os-protected" : "session-only";
12
+ return existing.app;
13
+ }
14
+ const store = existing ? existing.store : await import("keytar").then(module => module.default).catch(() => undefined);
15
+ this.storage = store ? "os-protected" : "session-only";
16
+ if (fresh) {
17
+ this.sessions.delete(key);
18
+ if (store) {
19
+ await store.deletePassword("mg-axi", key);
20
+ if (await store.getPassword("mg-axi", key) !== null)
21
+ throw new Error("Unable to wipe profile cache");
22
+ }
23
+ }
24
+ const configuration = {
25
+ auth: { clientId: profile.clientId, authority: `https://login.microsoftonline.com/${profile.tenantId}` },
26
+ system: { loggerOptions: { loggerCallback: () => { }, piiLoggingEnabled: false } },
27
+ };
28
+ const app = new PublicClientApplication({
29
+ ...configuration,
30
+ cache: store ? { cachePlugin: {
31
+ beforeCacheAccess: async (context) => {
32
+ const saved = await store.getPassword("mg-axi", key);
33
+ if (saved)
34
+ context.tokenCache.deserialize(saved);
35
+ },
36
+ afterCacheAccess: async (context) => {
37
+ if (!context.cacheHasChanged)
38
+ return;
39
+ const saved = context.tokenCache.serialize();
40
+ if (process.platform === "win32" && Buffer.byteLength(saved, "utf8") > 2560) {
41
+ await store.deletePassword("mg-axi", key);
42
+ if (await store.getPassword("mg-axi", key) !== null)
43
+ throw new Error("Unable to wipe profile cache");
44
+ const session = new PublicClientApplication(configuration);
45
+ session.getTokenCache().deserialize(saved);
46
+ this.sessions.set(key, { app: session });
47
+ this.storage = "session-only";
48
+ return;
49
+ }
50
+ await store.setPassword("mg-axi", key, saved);
51
+ },
52
+ } } : undefined,
53
+ });
54
+ this.sessions.set(key, { app, store });
55
+ return app;
56
+ }
57
+ async login(profile, method, scopes) {
58
+ // Each explicit login starts with a new client and a verified empty protected cache.
59
+ const app = await this.application(profile, true);
60
+ const result = method === "browser"
61
+ ? await app.acquireTokenInteractive({ scopes, openBrowser: async (url) => { await open(url); }, prompt: "select_account", errorTemplate: "Sign-in failed. Return to mg-axi for recovery guidance." })
62
+ : await app.acquireTokenByDeviceCode({ scopes, deviceCodeCallback: response => {
63
+ if (typeof response.userCode !== "string" || !response.userCode.trim() || typeof response.deviceCode !== "string" || !response.deviceCode.trim() || !Number.isFinite(response.expiresIn) || response.expiresIn <= 0)
64
+ throw new Error("Invalid device-code challenge");
65
+ // The one-time user challenge belongs only to explicit login stderr.
66
+ process.stderr.write(`Open https://microsoft.com/devicelogin and enter ${response.userCode}\n`);
67
+ } });
68
+ return this.result(profile, result);
69
+ }
70
+ async silent(profile, scopes) {
71
+ const app = await this.application(profile);
72
+ const accounts = (await app.getTokenCache().getAllAccounts()).filter(account => account.tenantId.toLowerCase() === profile.tenantId.toLowerCase());
73
+ if (accounts.length !== 1)
74
+ throw new Error("Explicit login required");
75
+ return this.result(profile, await app.acquireTokenSilent({ scopes, account: accounts[0] }));
76
+ }
77
+ result(profile, result) {
78
+ if (!result?.account || result.tenantId.toLowerCase() !== profile.tenantId.toLowerCase())
79
+ throw new Error("Configured tenant mismatch");
80
+ return { token: result.accessToken, expiresAt: result.expiresOn?.getTime() ?? NaN, tenantId: result.tenantId, clientId: profile.clientId, accountId: result.account.homeAccountId };
81
+ }
82
+ }
@@ -0,0 +1,529 @@
1
+ import { closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import { createHash, randomUUID } from "node:crypto";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { AxiError } from "axi-sdk-js";
6
+ import { encodeGraphPathSegment, GRAPH_HOST, redactGraphValue } from "./graph-session.js";
7
+ import { validateApplicationProfile, validateDelegatedProfile } from "./profiles.js";
8
+ // Shared mutation coordinator for reviewed named mutation families.
9
+ // The gate order follows az-axi's write gates as the reference: read-only default,
10
+ // allowWrites plus a scope allowlist, preview, --execute, --confirm,
11
+ // --if-match and a durable journal. The shared read-only
12
+ // scope guard in graph-session.ts stays in force for reads; write scopes are
13
+ // requested only through this coordinator path.
14
+ // Forced read-only beats every profile, mirroring AZ_AXI_READ_ONLY.
15
+ export const READ_ONLY_ENV = "MG_AXI_READ_ONLY";
16
+ export const WRITE_LOG_ENV = "MG_AXI_WRITE_LOG";
17
+ export function readOnlyForced(env = process.env) {
18
+ return env[READ_ONLY_ENV] === "1";
19
+ }
20
+ // Mutation intents and outcomes are journaled here: MG_AXI_WRITE_LOG when
21
+ // set, otherwise ~/.mg-axi/writes.log. A blank override falls back.
22
+ export function resolveWriteLogPath(env = process.env) {
23
+ const override = env[WRITE_LOG_ENV]?.trim();
24
+ if (override)
25
+ return override;
26
+ return join(homedir(), ".mg-axi", "writes.log");
27
+ }
28
+ export const mutationFetchTransport = async (request) => {
29
+ const response = await fetch(request.url, {
30
+ method: request.method,
31
+ headers: request.headers,
32
+ body: request.body,
33
+ signal: request.signal,
34
+ redirect: "manual",
35
+ });
36
+ const headers = {};
37
+ response.headers.forEach((value, key) => {
38
+ headers[key] = value;
39
+ });
40
+ return { status: response.status, headers, body: await response.text() };
41
+ };
42
+ const authorizations = new WeakMap();
43
+ function consumeMutationAuthorization(authorization, sender) {
44
+ if (authorizations.get(authorization) !== sender)
45
+ return false;
46
+ authorizations.delete(authorization);
47
+ return true;
48
+ }
49
+ function assertMutationDestination(url) {
50
+ let destination;
51
+ try {
52
+ destination = new URL(url);
53
+ }
54
+ catch {
55
+ throw new AxiError("Refusing unparseable mutation destination", "DESTINATION_DENIED", [
56
+ "Mutation destinations must be absolute HTTPS URLs on the commercial Graph host; no credential was sent",
57
+ ]);
58
+ }
59
+ if (destination.protocol !== "https:" || destination.hostname.toLowerCase() !== GRAPH_HOST
60
+ || destination.port || destination.username || destination.password
61
+ || !destination.pathname.startsWith("/v1.0/")) {
62
+ throw new AxiError("Refusing mutation destination outside the commercial Graph host", "DESTINATION_DENIED", [
63
+ `Mutations must stay under https://${GRAPH_HOST}/v1.0/; no credential was sent`,
64
+ ]);
65
+ }
66
+ }
67
+ function mutationFailure(message, code, suggestions, details) {
68
+ return Object.assign(new AxiError(message, code, suggestions), { details });
69
+ }
70
+ export function mutationHttpStatus(error) {
71
+ if (error && typeof error === "object" && "details" in error) {
72
+ const details = error.details;
73
+ if (details && typeof details === "object" && "httpStatus" in details) {
74
+ const status = details.httpStatus;
75
+ if (typeof status === "number" && Number.isInteger(status) && status > 0)
76
+ return status;
77
+ }
78
+ }
79
+ return 0;
80
+ }
81
+ export function mutationAccepted(error) {
82
+ if (error && typeof error === "object" && "details" in error) {
83
+ const details = error.details;
84
+ if (details && typeof details === "object" && "accepted" in details) {
85
+ return details.accepted === true;
86
+ }
87
+ }
88
+ return false;
89
+ }
90
+ export function mutationGraphFault(error) {
91
+ if (error && typeof error === "object" && "details" in error) {
92
+ const details = error.details;
93
+ if (details && typeof details === "object") {
94
+ const record = details;
95
+ if (typeof record.graphCode === "string" || typeof record.graphMessage === "string") {
96
+ return { code: typeof record.graphCode === "string" ? record.graphCode : "", message: typeof record.graphMessage === "string" ? record.graphMessage : "" };
97
+ }
98
+ }
99
+ }
100
+ return null;
101
+ }
102
+ const mutationsNotSent = new WeakSet();
103
+ export function mutationNotSent(error) {
104
+ return error instanceof Error && mutationsNotSent.has(error);
105
+ }
106
+ // Graph error code/message carried on mutation failures for idempotency
107
+ // classifiers (WRITE-01 duplicate detection). Never secrets: only the
108
+ // error code and a truncated message, matching the read faultBody shape.
109
+ function graphFaultDetail(body) {
110
+ if (!body)
111
+ return null;
112
+ try {
113
+ const parsed = JSON.parse(body);
114
+ const code = typeof parsed?.error?.code === "string" ? parsed.error.code : "";
115
+ const message = typeof parsed?.error?.message === "string" ? parsed.error.message.slice(0, 300) : "";
116
+ return code || message ? { code, message } : null;
117
+ }
118
+ catch {
119
+ return null;
120
+ }
121
+ }
122
+ function decodeMutationBody(response) {
123
+ const fault = graphFaultDetail(response.body);
124
+ const faultDetails = fault === null ? {} : { graphCode: fault.code, graphMessage: fault.message };
125
+ if (response.status === 401) {
126
+ throw mutationFailure(`Graph rejected the mutation credential (401)`, "AUTH_REQUIRED", [
127
+ "mg-axi login --profile <name> --scopes <comma-separated-Graph-scopes>",
128
+ "For application profiles, ask an administrator to grant application consent for the Graph .default audience",
129
+ ], { httpStatus: response.status });
130
+ }
131
+ if (response.status === 403) {
132
+ throw mutationFailure(`Graph denied the mutation (403): the grant, role, licence or policy prerequisite is missing and Graph does not say which`, "GRAPH_ERROR", [
133
+ "Check the catalogue permission and role guidance for this operation",
134
+ "A 403 never proves which prerequisite is missing; do not retry blindly",
135
+ ], { httpStatus: response.status });
136
+ }
137
+ if (response.status === 404) {
138
+ throw mutationFailure(`Graph reports the mutation target as not found or inaccessible (404); Graph does not distinguish missing from hidden`, "GRAPH_ERROR", ["Verify the bound identifier; absence is not proof of nonexistence"], { httpStatus: response.status });
139
+ }
140
+ if (response.status < 200 || response.status > 299) {
141
+ throw mutationFailure(`Graph mutation returned status ${response.status}`, "GRAPH_ERROR", [
142
+ "Read back the target before doing anything else; never replay this intent",
143
+ ], { httpStatus: response.status, ...faultDetails });
144
+ }
145
+ try {
146
+ return response.body ? JSON.parse(response.body) : {};
147
+ }
148
+ catch {
149
+ // The server accepted the mutation; only its response body was discarded.
150
+ throw mutationFailure("Graph mutation response is not valid JSON", "RESPONSE_INVALID", ["The server accepted the mutation but its response body was discarded"], { httpStatus: response.status, accepted: true });
151
+ }
152
+ }
153
+ // The authorized mutation sender. It shares credential providers and the
154
+ // transport seam with reads while keeping mutation policy explicit: every
155
+ // send needs a coordinator authorization minted after the write gates pass.
156
+ // The bound profile copy is private; no profile snapshot is exposed.
157
+ export function createMutationSender(args) {
158
+ const { delegated, application, transport } = args;
159
+ const bound = args.profile.mode === "application" ? validateApplicationProfile(args.profile) : validateDelegatedProfile(args.profile);
160
+ async function credential(scopes, signal) {
161
+ signal?.throwIfAborted();
162
+ if (bound.mode === "delegated") {
163
+ if (scopes === undefined) {
164
+ throw new AxiError("Delegated mutations require explicit Graph scopes", "VALIDATION_ERROR", [
165
+ "Pass the operation's documented delegated permission using full https://graph.microsoft.com/ scope names",
166
+ ]);
167
+ }
168
+ return (await delegated.credential(validateDelegatedProfile(bound), scopes)).token;
169
+ }
170
+ if (scopes !== undefined) {
171
+ throw new AxiError("Application profiles use the configured Graph .default audience; delegated scopes are unavailable", "VALIDATION_ERROR", [
172
+ "mg-axi profile show --profile <name>",
173
+ ]);
174
+ }
175
+ return (await application.credential(validateApplicationProfile(bound))).token;
176
+ }
177
+ async function send(authorization, options) {
178
+ let handedOff = false;
179
+ try {
180
+ if (!consumeMutationAuthorization(authorization, sender)) {
181
+ throw new AxiError("Refusing mutation without coordinator authorization", "OPERATION_BLOCKED", [
182
+ "Mutations are sent only with a coordinator authorization minted after the write gates pass; no credential was sent",
183
+ ]);
184
+ }
185
+ if (!["POST", "PUT", "PATCH", "DELETE"].includes(authorization.method)) {
186
+ throw new AxiError(`Refusing mutation with method: ${authorization.method}`, "OPERATION_BLOCKED", [
187
+ "Mutations use POST, PUT, PATCH or DELETE only",
188
+ ]);
189
+ }
190
+ // Re-check the bound destination after authorization, before credentials.
191
+ assertMutationDestination(authorization.url);
192
+ if (options?.ifMatch !== undefined && (!options.ifMatch.trim() || /[\x00-\x1f\x7f]/.test(options.ifMatch))) {
193
+ throw new AxiError("Invalid If-Match value", "VALIDATION_ERROR", [
194
+ "Provide the exact entity tag returned by the previewed read",
195
+ ]);
196
+ }
197
+ options?.signal?.throwIfAborted();
198
+ if (readOnlyForced()) {
199
+ throw new AxiError("blocked: forced read-only disables mutations", "WRITES_DISABLED", [
200
+ "MG_AXI_READ_ONLY=1 overrides profile write enablement",
201
+ ]);
202
+ }
203
+ // Provider checks bind the acquired credential to the snapshotted
204
+ // tenant and client; a credential for another identity fails here and
205
+ // nothing is sent.
206
+ const token = await credential(options?.scopes, options?.signal);
207
+ options?.signal?.throwIfAborted();
208
+ assertMutationDestination(authorization.url);
209
+ if (readOnlyForced()) {
210
+ throw new AxiError("blocked: forced read-only disables mutations", "WRITES_DISABLED", [
211
+ "MG_AXI_READ_ONLY=1 overrides profile write enablement",
212
+ ]);
213
+ }
214
+ // The request is handed to the transport here: any later failure is
215
+ // ambiguous (bytes may have gone out) and is never replayed.
216
+ handedOff = true;
217
+ const response = await transport({
218
+ method: authorization.method,
219
+ url: authorization.url,
220
+ headers: {
221
+ Authorization: `Bearer ${token}`,
222
+ Accept: "application/json",
223
+ "client-request-id": randomUUID(),
224
+ ...(options?.body !== undefined ? { "Content-Type": "application/json" } : {}),
225
+ ...(options?.ifMatch !== undefined ? { "If-Match": options.ifMatch } : {}),
226
+ },
227
+ ...(options?.body !== undefined ? { body: options.body } : {}),
228
+ ...(options?.signal ? { signal: options.signal } : {}),
229
+ });
230
+ options?.signal?.throwIfAborted();
231
+ return { status: response.status, body: decodeMutationBody(response) };
232
+ }
233
+ catch (error) {
234
+ if (!handedOff && error instanceof Error)
235
+ mutationsNotSent.add(error);
236
+ throw error;
237
+ }
238
+ }
239
+ const sender = { send };
240
+ return sender;
241
+ }
242
+ function flushDirectory(path) {
243
+ // Node cannot open directory handles on Windows; journal fsync still
244
+ // applies, and recordAudit pre-creates the journal file there instead.
245
+ if (process.platform === "win32")
246
+ return;
247
+ const fd = openSync(path, "r");
248
+ try {
249
+ fsyncSync(fd);
250
+ }
251
+ finally {
252
+ closeSync(fd);
253
+ }
254
+ }
255
+ function serialize(value) {
256
+ try {
257
+ const serialized = JSON.stringify(value);
258
+ if (value !== undefined && serialized === undefined)
259
+ throw new Error("Not JSON");
260
+ return serialized;
261
+ }
262
+ catch {
263
+ throw new AxiError("Mutation state or payload is not serializable", "VALIDATION_ERROR", [
264
+ "Provide JSON-serializable current state and payload",
265
+ ]);
266
+ }
267
+ }
268
+ export function createMutationCoordinator(args) {
269
+ const { profile, delegated, application, transport } = args;
270
+ const clock = args.clock ?? Date.now;
271
+ const journalPath = args.journalPath ?? resolveWriteLogPath();
272
+ const validated = profile.mode === "application" ? validateApplicationProfile(profile) : validateDelegatedProfile(profile);
273
+ const scope = Object.freeze({
274
+ tenantId: validated.tenantId,
275
+ allowWrites: validated.writes?.allowWrites === true,
276
+ operations: Object.freeze([...(validated.writes?.operations ?? [])]),
277
+ });
278
+ const sender = createMutationSender({ profile: validated, delegated, application, transport });
279
+ function enforceWriteGates(definition) {
280
+ // Gate 1: forced read-only overrides every profile.
281
+ // Gate 2: the profile must opt in through hand-edited configuration.
282
+ if (readOnlyForced() || !scope.allowWrites) {
283
+ throw new AxiError(`blocked: writes are disabled for this profile (${definition.method} ${definition.operation})`, "WRITES_DISABLED", [
284
+ "Writes are disabled for this profile",
285
+ "Enable named writes explicitly in the profile configuration; see README.md for the write gates",
286
+ ]);
287
+ }
288
+ // Classification precedes scope gating, mirroring az-axi: a malformed
289
+ // definition fails as VALIDATION_ERROR before membership is considered.
290
+ if (!definition.operation.trim()) {
291
+ throw new AxiError("Mutation operation must be a non-empty name", "VALIDATION_ERROR", [
292
+ "Name the approved operation the coordinator should authorize",
293
+ ]);
294
+ }
295
+ if (!definition.target.trim()) {
296
+ throw new AxiError("Mutation target must be a non-empty name", "VALIDATION_ERROR", [
297
+ "Name the exact target this mutation would change",
298
+ ]);
299
+ }
300
+ if (definition.version !== "v1.0") {
301
+ throw new AxiError(`Unsupported mutation version ${definition.version}`, "VALIDATION_ERROR", [
302
+ "Mutations are bound to v1.0; beta writes stay blocked",
303
+ ]);
304
+ }
305
+ // Gate 3: the operation must fall inside the profile's own configured scope.
306
+ if (!scope.operations.includes(definition.operation)) {
307
+ throw new AxiError(`blocked: operation '${definition.operation}' is outside the write scope of this profile (${definition.method} request)`, "OPERATION_NOT_WRITABLE", [
308
+ "Writes are limited to this profile's configured operations",
309
+ "The named write must be in the profile's writes.operations allowlist; see README.md for configuration",
310
+ ]);
311
+ }
312
+ }
313
+ function boundMutationUrl(definition) {
314
+ const prefix = "/v1.0/";
315
+ const segments = definition.path.split("/");
316
+ if (!definition.path.startsWith(prefix) || segments[0] !== ""
317
+ || segments.slice(1).some(segment => !segment || segment === "." || segment === "..")
318
+ || /[\s\x00-\x1f\x7f\\?#{}]/.test(definition.path)) {
319
+ throw new AxiError("Refusing mutation path outside the bound operation scope", "VALIDATION_ERROR", [
320
+ `Mutation paths must stay under ${prefix} without traversal, query strings or fragments`,
321
+ ]);
322
+ }
323
+ for (const segment of segments.slice(1)) {
324
+ if (segment === "$ref" && definition.operation === "mg.entra.group.member.add" && definition.method === "POST"
325
+ && /^\/v1\.0\/groups\/[^/]+\/members\/\$ref$/.test(definition.path))
326
+ continue;
327
+ let decoded;
328
+ try {
329
+ decoded = decodeURIComponent(segment);
330
+ }
331
+ catch {
332
+ throw new AxiError("Invalid mutation path encoding", "VALIDATION_ERROR", [
333
+ "Bind one encoded resource identifier per path segment",
334
+ ]);
335
+ }
336
+ encodeGraphPathSegment(decoded);
337
+ }
338
+ return `https://${GRAPH_HOST}${definition.path}`;
339
+ }
340
+ function preview(definition, current, isNoop) {
341
+ enforceWriteGates(definition);
342
+ const url = boundMutationUrl(definition);
343
+ return Object.freeze({
344
+ operation: definition.operation,
345
+ method: definition.method,
346
+ url,
347
+ effect: definition.effect,
348
+ target: definition.target,
349
+ noop: current === undefined || isNoop === undefined ? false : isNoop(current),
350
+ currentState: current === undefined ? null : serialize(redactGraphValue(current)),
351
+ proposedChange: definition.payload === undefined ? null : serialize(redactGraphValue(definition.payload)),
352
+ });
353
+ }
354
+ // Metadata only: never payloads, headers, secrets or bodies.
355
+ function recordAudit(record) {
356
+ const safe = redactGraphValue({ ...record, time: new Date(clock()).toISOString() });
357
+ const line = JSON.stringify({ ...safe, intentKey: record.intentKey });
358
+ try {
359
+ const directory = dirname(journalPath);
360
+ const firstCreated = mkdirSync(directory, { recursive: true, mode: 0o700 });
361
+ if (firstCreated) {
362
+ let path = directory;
363
+ const parent = dirname(firstCreated);
364
+ while (path !== parent) {
365
+ flushDirectory(path);
366
+ path = dirname(path);
367
+ }
368
+ flushDirectory(parent);
369
+ }
370
+ if (process.platform === "win32" && !existsSync(journalPath)) {
371
+ const journal = openSync(journalPath, "a", 0o600);
372
+ try {
373
+ fsyncSync(journal);
374
+ }
375
+ finally {
376
+ closeSync(journal);
377
+ }
378
+ }
379
+ const lockPath = `${journalPath}.lock`;
380
+ const lock = openSync(lockPath, "wx", 0o600);
381
+ try {
382
+ const created = !existsSync(journalPath);
383
+ const journal = openSync(journalPath, "a+", 0o600);
384
+ try {
385
+ if (record.kind === "intent") {
386
+ const contents = readFileSync(journal, "utf8");
387
+ if (contents && !contents.endsWith("\n"))
388
+ throw new Error("Incomplete mutation journal");
389
+ const records = contents.split("\n").filter(Boolean)
390
+ .map(entry => JSON.parse(entry));
391
+ if (records.some(entry => !entry || !["intent", "outcome"].includes(entry.kind)
392
+ || typeof entry.intentKey !== "string" || !/^[a-f0-9]{64}$/.test(entry.intentKey))) {
393
+ throw new Error("Invalid mutation journal");
394
+ }
395
+ // No automatic replay: a recreated coordinator refuses an intent
396
+ // recorded without a terminal outcome, and a duplicate intent id
397
+ // is never reserved twice.
398
+ if (records.some(entry => entry.kind === "intent" && entry.intentKey === record.intentKey)) {
399
+ throw new AxiError(`blocked: intent '${record.id}' was already reserved`, "ALREADY_EXECUTED", [
400
+ `Intent '${record.id}' requires manual reconciliation; read back its target and never resend it`,
401
+ ]);
402
+ }
403
+ }
404
+ writeFileSync(journal, `${line}\n`);
405
+ fsyncSync(journal);
406
+ if (created)
407
+ flushDirectory(directory);
408
+ }
409
+ finally {
410
+ closeSync(journal);
411
+ }
412
+ }
413
+ finally {
414
+ closeSync(lock);
415
+ unlinkSync(lockPath);
416
+ }
417
+ }
418
+ catch (error) {
419
+ if (error instanceof AxiError && error.code === "ALREADY_EXECUTED")
420
+ throw error;
421
+ if (record.kind === "intent") {
422
+ throw new AxiError(`Mutation intent '${record.id}' could not be recorded`, "INTENT_NOT_RECORDED", [
423
+ "The mutation was not sent",
424
+ `Check the audit path ${journalPath}; a remaining lock requires manual reconciliation before removal`,
425
+ ]);
426
+ }
427
+ throw new AxiError(`Mutation outcome '${record.id}' could not be recorded`, "OUTCOME_NOT_RECORDED", [
428
+ "The mutation may have been applied; read back the target before doing anything else",
429
+ `Check the audit path ${journalPath}`,
430
+ ]);
431
+ }
432
+ }
433
+ function metadata(definition, seen, id, ifMatch) {
434
+ return {
435
+ id,
436
+ intentKey: createHash("sha256").update(id).digest("hex"),
437
+ profile: scope.tenantId,
438
+ operation: definition.operation,
439
+ method: definition.method,
440
+ url: seen.url,
441
+ target: definition.target,
442
+ effect: definition.effect,
443
+ ...(ifMatch !== undefined ? { ifMatch } : {}),
444
+ };
445
+ }
446
+ async function execute(definition, options) {
447
+ const body = serialize(definition.payload);
448
+ definition = { ...definition, ...(body === undefined ? {} : { payload: JSON.parse(body) }) };
449
+ options = { ...options };
450
+ const seen = preview(definition, await options.readState(), options.isNoop);
451
+ // Gate 4: without --execute the caller runs the dry run instead.
452
+ if (options.execute !== true)
453
+ return { kind: "dry-run", preview: seen };
454
+ // Verified already-desired state is a no-op: nothing is sent.
455
+ if (seen.noop)
456
+ return { kind: "noop", preview: seen };
457
+ // Gate 5: disruptive mutations need the target name back.
458
+ if (definition.effect === "disruptive") {
459
+ if (options.confirm === undefined) {
460
+ throw new AxiError(`blocked: disruptive ${definition.method} needs --confirm '${definition.target}'`, "CONFIRM_REQUIRED", [`Re-run with --confirm '${definition.target}'`]);
461
+ }
462
+ if (options.confirm !== definition.target) {
463
+ throw new AxiError(`blocked: --confirm '${options.confirm}' does not match target '${definition.target}'`, "CONFIRM_MISMATCH", [`Re-run with --confirm '${definition.target}'`]);
464
+ }
465
+ }
466
+ const id = options.intentId ?? randomUUID();
467
+ // A re-read alone is not atomic protection; conditional writes travel as
468
+ // If-Match only where the endpoint supports them (WRITE-N evidence).
469
+ const meta = metadata(definition, seen, id, options.ifMatch);
470
+ // Failure to record intent blocks the send.
471
+ recordAudit({ ...meta, kind: "intent" });
472
+ // Re-read before the single send; a failed re-read aborts without sending.
473
+ let fresh;
474
+ try {
475
+ fresh = await options.readState();
476
+ }
477
+ catch (error) {
478
+ recordAudit({ ...meta, kind: "outcome", httpStatus: 0, outcome: "NOT_SENT" });
479
+ throw error;
480
+ }
481
+ if (options.isNoop?.(fresh) === true) {
482
+ recordAudit({ ...meta, kind: "outcome", httpStatus: 0, outcome: "NOT_SENT" });
483
+ return { kind: "noop", preview: seen };
484
+ }
485
+ const authorization = Object.freeze({ nonce: randomUUID(), method: definition.method, url: seen.url });
486
+ authorizations.set(authorization, sender);
487
+ let sent;
488
+ try {
489
+ sent = await sender.send(authorization, {
490
+ ...(body !== undefined ? { body } : {}),
491
+ ...(options.ifMatch !== undefined ? { ifMatch: options.ifMatch } : {}),
492
+ ...(options.scopes !== undefined ? { scopes: options.scopes } : {}),
493
+ });
494
+ }
495
+ catch (error) {
496
+ if (mutationNotSent(error)) {
497
+ recordAudit({ ...meta, kind: "outcome", httpStatus: 0, outcome: "NOT_SENT" });
498
+ throw error;
499
+ }
500
+ const httpStatus = mutationHttpStatus(error);
501
+ if (httpStatus > 0 && mutationAccepted(error)) {
502
+ recordAudit({ ...meta, kind: "outcome", httpStatus, outcome: "SUCCESS" });
503
+ return { kind: "success", preview: seen, auditId: id, status: httpStatus, response: {} };
504
+ }
505
+ // Idempotent already-applied outcome (WRITE-01 duplicate reference):
506
+ // the desired state holds, so this is a no-op, not a failure. The
507
+ // intent still cannot replay: the recorded intent blocks any resend.
508
+ if (httpStatus > 0 && options.isDuplicate?.(error) === true) {
509
+ recordAudit({ ...meta, kind: "outcome", httpStatus, outcome: "NOOP" });
510
+ return { kind: "noop", preview: seen, auditId: id, duplicate: true };
511
+ }
512
+ if (httpStatus >= 400 && httpStatus < 500 && httpStatus !== 408) {
513
+ recordAudit({ ...meta, kind: "outcome", httpStatus, outcome: "FAILED" });
514
+ return { kind: "failed", preview: seen, auditId: id, status: httpStatus };
515
+ }
516
+ recordAudit({ ...meta, kind: "outcome", httpStatus, outcome: "OUTCOME_UNKNOWN" });
517
+ return {
518
+ kind: "unknown",
519
+ preview: seen,
520
+ auditId: id,
521
+ httpStatus,
522
+ guidance: `Mutation ${definition.operation} may or may not have been applied (audit ${id}); read back target '${definition.target}' before doing anything else; never replay this intent`,
523
+ };
524
+ }
525
+ recordAudit({ ...meta, kind: "outcome", httpStatus: sent.status, outcome: "SUCCESS" });
526
+ return { kind: "success", preview: seen, auditId: id, status: sent.status, response: sent.body };
527
+ }
528
+ return { scope, preview, execute };
529
+ }