@knowttl/mg-axi 0.1.4 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -1,10 +1,13 @@
1
1
  import { encode } from "@toon-format/toon";
2
- import { existsSync } from "node:fs";
3
- import { dirname, join } from "node:path";
2
+ import { existsSync, realpathSync, statSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join, relative, isAbsolute, sep } from "node:path";
4
5
  import { AxiError, installSessionStartHooks, runAxiCli } from "axi-sdk-js";
5
6
  import { LEAVES, home, leafHelp, operationFor, resolveCommand, DESCRIPTION, TOP_LEVEL_HELP } from "./catalogue.js";
6
7
  import { VERSION } from "./version.js";
7
8
  import { Profiles } from "./profiles.js";
9
+ import { authStatus } from "./auth-status.js";
10
+ import { redactSecrets } from "./redact.js";
8
11
  import { GraphSession, MAX_CURSOR_BYTES } from "./graph-session.js";
9
12
  import { listUsers, showUser } from "./entra-users.js";
10
13
  import { updateUserAccount } from "./entra-user-update.js";
@@ -48,22 +51,21 @@ import { addGroupMember } from "./entra-group-member-add.js";
48
51
  import { fetchTransport } from "./api.js";
49
52
  import { doctorTargets, runDoctor } from "./doctor.js";
50
53
  import { setupView } from "./setup.js";
51
- function localHome(name) {
54
+ function localHome(name, config) {
52
55
  const output = home();
53
- const store = new Profiles();
54
- if (name || store.list().length) {
55
- try {
56
- const selected = store.resolve(name);
57
- output.profile = selected.name;
58
- output.tenant = selected.profile.tenantId;
59
- }
60
- catch (error) {
61
- if (name || !(error instanceof AxiError) || error.code !== "AUTH_REQUIRED")
62
- throw error;
56
+ const store = new Profiles(config);
57
+ try {
58
+ const selected = store.resolve(name);
59
+ output.profile = selected.name;
60
+ output.tenant = selected.profile.tenantId;
61
+ }
62
+ catch (error) {
63
+ if ((name?.trim() ? name : undefined) || !(error instanceof AxiError) || !["PROFILE_REQUIRED", "PROFILE_AMBIGUOUS"].includes(error.code))
64
+ throw error;
65
+ if (error.code === "PROFILE_AMBIGUOUS")
63
66
  output.profile = "unavailable: no default profile selected";
64
- }
65
67
  }
66
- output.help.unshift("npx -y @knowttl/mg-axi profile list", "npx -y @knowttl/mg-axi login --help");
68
+ output.help.unshift("npx -y @knowttl/mg-axi profile list", "npx -y @knowttl/mg-axi auth login --help");
67
69
  return output;
68
70
  }
69
71
  // One dispatch for the hook installer: resolve the sibling hook entry point
@@ -73,17 +75,34 @@ function localHome(name) {
73
75
  // summary from src/hook.ts. Install failures throw loudly; hook failures
74
76
  // never do.
75
77
  function runSetupHooks() {
76
- const hookPath = join(dirname(process.argv[1] ?? ""), "mg-axi-hook.js");
78
+ const hookPath = join(dirname(realpathSync(process.argv[1] ?? "")), "mg-axi-hook.js");
77
79
  if (!existsSync(hookPath)) {
78
80
  throw new AxiError("Cannot locate the mg-axi-hook entry point", "HOOK_INSTALL_FAILED", [
79
81
  "Reinstall @knowttl/mg-axi so dist/bin/mg-axi-hook.js sits next to the main entry point",
80
82
  "Run mg-axi setup hooks again after reinstalling",
81
83
  ]);
82
84
  }
85
+ const target = realpathSync(hookPath);
86
+ const temporaryRoots = [tmpdir(), "/tmp", "/var/tmp"].map(path => existsSync(path) ? realpathSync(path) : path);
87
+ const inTemporaryDirectory = temporaryRoots.some(root => {
88
+ const path = relative(root, target);
89
+ return path === "" || (path !== ".." && !path.startsWith(`..${sep}`) && !isAbsolute(path));
90
+ });
91
+ let inWorktree = false;
92
+ for (let directory = dirname(target); directory !== dirname(directory); directory = dirname(directory)) {
93
+ const gitPath = join(directory, ".git");
94
+ if (existsSync(gitPath) && statSync(gitPath).isFile())
95
+ inWorktree = true;
96
+ }
97
+ if (inTemporaryDirectory || inWorktree || /(?:^|[/\\])(?:\.no-mistakes|\.treehouse|worktrees)(?:[/\\]|$)/.test(target)) {
98
+ throw new AxiError("Cannot install hooks from a temporary directory or worktree", "HOOK_INSTALL_FAILED", [
99
+ "Install @knowttl/mg-axi globally, then run mg-axi setup hooks from that installation",
100
+ ]);
101
+ }
83
102
  const failures = [];
84
103
  installSessionStartHooks({
85
104
  marker: "mg-axi-hook",
86
- execPath: hookPath,
105
+ execPath: target,
87
106
  binaryNames: ["mg-axi-hook"],
88
107
  distEntrypoints: ["dist/bin/mg-axi-hook.js"],
89
108
  onError: (message) => { failures.push(message); },
@@ -115,23 +134,30 @@ export async function executeArgv(argv, overrides = {}) {
115
134
  if (flags.help)
116
135
  return leafHelp(leaf);
117
136
  if (leaf.path === "home")
118
- return localHome(flags.profile);
119
- const profiles = new Profiles();
137
+ return localHome(flags.profile, flags.config);
138
+ const profiles = new Profiles(flags.config);
139
+ if (leaf.path === "config path")
140
+ return { config: profiles.path };
120
141
  if (leaf.path === "profile create") {
121
- const credentialFlags = ["certificate-thumbprint", "client-secret-env", "client-secret-file", "certificate-key-file", "certificate-key-env", "federated-token-file-env"];
142
+ const credentialFlags = ["certificate-thumbprint-sha256", "client-secret-env", "client-secret-file", "certificate-key-file", "certificate-key-env", "federated-token-file"];
122
143
  if (String(flags.mode ?? "delegated") !== "application" && (flags.federated || credentialFlags.some(name => flags[name] !== undefined)))
123
144
  throw new AxiError("Certificate, client-secret and federated credentials belong to application profiles; pass --mode application", "VALIDATION_ERROR", [leafHelp(leaf)]);
124
145
  const optional = (name) => flags[name] === undefined ? undefined : String(flags[name]);
125
146
  return profiles.create(String(flags.name), String(flags.tenant), String(flags.client), String(flags.cloud), !!flags["allow-device-code"], String(flags.mode ?? "delegated") === "application"
126
- ? { certificateThumbprint: optional("certificate-thumbprint"), federated: !!flags.federated, clientSecretEnv: optional("client-secret-env"), clientSecretFile: optional("client-secret-file"), certificateKeyFile: optional("certificate-key-file"), certificateKeyEnv: optional("certificate-key-env"), federatedTokenFileEnv: optional("federated-token-file-env") }
147
+ ? { certificateThumbprint: optional("certificate-thumbprint-sha256"), federated: !!flags.federated, clientSecretEnv: optional("client-secret-env"), clientSecretFile: optional("client-secret-file"), certificateKeyFile: optional("certificate-key-file"), certificateKeyEnv: optional("certificate-key-env"), federatedTokenFile: optional("federated-token-file") }
127
148
  : undefined);
128
149
  }
129
150
  if (leaf.path === "profile list") {
130
151
  const items = profiles.list();
131
- return items.length ? { profiles: items, help: ["npx -y @knowttl/mg-axi profile show --profile <name>", "npx -y @knowttl/mg-axi login --help"] } : { profiles: "0 profiles configured", help: ["npx -y @knowttl/mg-axi profile create --help"] };
152
+ return items.length ? { profiles: items, help: ["npx -y @knowttl/mg-axi profile show --profile <name>", "npx -y @knowttl/mg-axi auth login --help"] } : { profiles: "0 profiles configured", help: ["npx -y @knowttl/mg-axi profile create --help"] };
132
153
  }
133
154
  if (leaf.path === "profile show")
134
155
  return profiles.resolve(flags.profile);
156
+ if (leaf.path === "auth status") {
157
+ const selected = profiles.resolve(flags.profile);
158
+ profiles.verifyPermissions();
159
+ return authStatus(selected.name, selected.profile);
160
+ }
135
161
  if (leaf.path === "setup")
136
162
  return setupView(profiles);
137
163
  if (leaf.path === "setup hooks")
@@ -153,18 +179,20 @@ export async function executeArgv(argv, overrides = {}) {
153
179
  const session = new GraphSession({ delegated, application, transport: overrides.transport ?? fetchTransport });
154
180
  // A failing profile still reports its bounded read on stdout; the
155
181
  // nonzero exit travels through process state, never the payload.
156
- const result = await runDoctor({ store: profiles, names, session });
182
+ const selected = names.length === 1 ? profiles.resolve(flags.profile) : undefined;
183
+ const result = await runDoctor({ store: profiles, names, session, selected });
157
184
  if (result.failed)
158
185
  process.exitCode = 1;
159
186
  return result.output;
160
187
  }
161
- if (leaf.path === "login") {
188
+ if (leaf.path === "login" || leaf.path === "auth login" || leaf.path === "auth logout") {
162
189
  const selected = profiles.resolve(flags.profile);
163
190
  if (selected.profile.mode !== "delegated")
164
191
  throw new AxiError("Application profiles authenticate with client credentials; interactive login is unavailable", "VALIDATION_ERROR", ["mg-axi profile show --profile <name>", "Application tokens are acquired silently with the configured Graph .default audience"]);
165
192
  const { DelegatedAuth } = await import("./auth.js");
166
193
  const { MsalProvider } = await import("./msal-provider.js");
167
- return { profile: selected.name, ...await new DelegatedAuth(new MsalProvider()).login(selected.profile, String(flags.method ?? "browser"), String(flags.scopes).split(",")) };
194
+ const service = overrides.delegated ?? new DelegatedAuth(new MsalProvider());
195
+ return { profile: selected.name, ...await (leaf.path === "auth logout" ? service.logout(selected.profile) : service.login(selected.profile, String(flags.method ?? "browser"), String(flags.scopes).split(","))) };
168
196
  }
169
197
  if (leaf.path === "entra conditional-access policy list" || leaf.path === "entra conditional-access policy show" || leaf.path === "entra conditional-access named-location list" || leaf.path === "entra conditional-access named-location show" || leaf.path === "entra conditional-access auth-strength-policy list" || leaf.path === "entra conditional-access auth-strength-policy show" || leaf.path === "entra conditional-access combination-configuration list" || leaf.path === "entra conditional-access combination-configuration show" || leaf.path === "entra conditional-access auth-method-mode list" || leaf.path === "entra conditional-access auth-method-mode show" || leaf.path === "entra conditional-access template list" || leaf.path === "entra conditional-access template show" || leaf.path === "entra conditional-access auth-context list" || leaf.path === "entra conditional-access auth-context show" || leaf.path === "entra conditional-access deleted-policy list" || leaf.path === "entra conditional-access deleted-policy show" || leaf.path === "entra conditional-access deleted-named-location list" || leaf.path === "entra conditional-access deleted-named-location show") {
170
198
  const selected = profiles.resolve(flags.profile);
@@ -1432,12 +1460,15 @@ export async function executeArgv(argv, overrides = {}) {
1432
1460
  throw new AxiError(`Command is not executable: ${operation?.disposition ?? "unavailable"} (${operation?.owningSlice ?? "no inventory mapping"})`, "NOT_IMPLEMENTED", [leafHelp(leaf)]);
1433
1461
  }
1434
1462
  export async function main() {
1463
+ const stderrWrite = process.stderr.write.bind(process.stderr);
1464
+ process.stderr.write = ((chunk, ...args) => stderrWrite(redactSecrets(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8")), ...args));
1435
1465
  await runAxiCli({
1436
1466
  description: DESCRIPTION,
1437
1467
  version: VERSION,
1438
1468
  topLevelHelp: TOP_LEVEL_HELP,
1439
1469
  // Strict resolution owns leaf help, so SDK help cannot bypass validation.
1440
1470
  argv: ["dispatch"],
1471
+ stdout: { write: (value) => process.stdout.write(redactSecrets(value)) },
1441
1472
  home: () => localHome(),
1442
1473
  commands: {
1443
1474
  dispatch: async () => {
@@ -1447,7 +1478,7 @@ export async function main() {
1447
1478
  },
1448
1479
  },
1449
1480
  formatError: error => ({
1450
- output: `${encode(error instanceof AxiError ? { error: error.message, code: error.code, help: error.suggestions } : { error: "Unable to run mg-axi", help: ["mg-axi --help"] })}\n`,
1481
+ output: `${redactSecrets(encode(error instanceof AxiError ? { error: error.message, code: error.code, help: error.suggestions } : { error: "Unable to run mg-axi", help: ["mg-axi --help"] }))}\n`,
1451
1482
  exitCode: error instanceof AxiError && error.code === "VALIDATION_ERROR" ? 2 : 1,
1452
1483
  }),
1453
1484
  });
@@ -0,0 +1,78 @@
1
+ import { rmSync } from "node:fs";
2
+ import { AxiError } from "axi-sdk-js";
3
+ import { statePath } from "./paths.js";
4
+ import { PrivateFiles } from "./private-file.js";
5
+ import { profileName } from "./profiles.js";
6
+ import { registerCache } from "./redact.js";
7
+ const object = (value) => !!value && typeof value === "object" && !Array.isArray(value);
8
+ // The only persistent credential backend. Access tokens never leave memory.
9
+ export class Credentials {
10
+ path;
11
+ files;
12
+ constructor(path = statePath("credentials.json"), files = new PrivateFiles()) {
13
+ this.path = path;
14
+ this.files = files;
15
+ }
16
+ load(verifyAcl = false) {
17
+ let text;
18
+ try {
19
+ text = this.files.read(this.path, true, verifyAcl);
20
+ }
21
+ catch (error) {
22
+ if (error.code === "ENOENT")
23
+ return { version: 1, profiles: Object.create(null) };
24
+ throw error;
25
+ }
26
+ try {
27
+ const value = JSON.parse(text);
28
+ if (!object(value) || value.version !== 1 || !object(value.profiles) || Object.keys(value).some(key => !["version", "profiles"].includes(key)))
29
+ throw new Error("Invalid store");
30
+ for (const [name, entry] of Object.entries(value.profiles)) {
31
+ if (!/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/.test(name) || !object(entry) || typeof entry.tenantId !== "string" || typeof entry.clientId !== "string" || typeof entry.cache !== "string" ||
32
+ Object.keys(entry).some(key => !["tenantId", "clientId", "cache"].includes(key)) || !object(JSON.parse(entry.cache)))
33
+ throw new Error("Invalid cache");
34
+ }
35
+ return value;
36
+ }
37
+ catch (error) {
38
+ if (error instanceof AxiError)
39
+ throw error;
40
+ throw new AxiError(`Invalid credential store ${this.path}`, "CONFIG_INVALID", ["Restore a valid protected credentials.json before signing in again"]);
41
+ }
42
+ }
43
+ name(profile) {
44
+ const name = profileName(profile);
45
+ if (!name)
46
+ throw new AxiError("Stored credentials require a named profile", "CONFIG_INVALID");
47
+ return name;
48
+ }
49
+ read(profile, verifyAcl = false) {
50
+ const records = this.load(verifyAcl).profiles;
51
+ const name = this.name(profile);
52
+ const entry = Object.hasOwn(records, name) ? records[name] : undefined;
53
+ if (!entry)
54
+ return undefined;
55
+ if (entry.tenantId.toLowerCase() !== profile.tenantId.toLowerCase() || entry.clientId.toLowerCase() !== profile.clientId.toLowerCase())
56
+ throw new AxiError("Stored credential binding does not match the selected tenant and client", "AUTH_REQUIRED", ["mg-axi auth login --profile <name> --scopes <comma-separated-Graph-scopes>"]);
57
+ return registerCache(entry.cache);
58
+ }
59
+ write(profile, serialized) {
60
+ registerCache(serialized);
61
+ const cache = JSON.parse(serialized);
62
+ delete cache.AccessToken;
63
+ const store = this.load();
64
+ Object.defineProperty(store.profiles, this.name(profile), { value: { tenantId: profile.tenantId, clientId: profile.clientId, cache: JSON.stringify(cache) }, enumerable: true, configurable: true, writable: true });
65
+ this.files.write(this.path, `${JSON.stringify(store, null, 2)}\n`);
66
+ }
67
+ remove(profile) {
68
+ const store = this.load();
69
+ const name = this.name(profile);
70
+ if (!Object.hasOwn(store.profiles, name))
71
+ return;
72
+ delete store.profiles[name];
73
+ if (Object.keys(store.profiles).length)
74
+ this.files.write(this.path, `${JSON.stringify(store, null, 2)}\n`);
75
+ else
76
+ rmSync(this.path);
77
+ }
78
+ }
package/dist/docs.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { DESCRIPTION, HOME_HELP, LEAVES } from "./catalogue.js";
3
+ const UNSUPPORTED_READ_NOTE = "Graph v1.0 does not support the legacy `entra scoped-role-membership list/show`, `entra domain-dns-record list` or `entra directory-object list` routes; those commands fail locally with supported alternatives and their root routes are not available through `api get`.";
3
4
  // PACK-01: single source for generated release records. The committed
4
5
  // skills/mg-axi/SKILL.md and docs/coverage.md are generated from the templates
5
6
  // below, the executable catalogue and the discovery inventory, so help, skill
@@ -138,7 +139,7 @@ export function skillDescription() {
138
139
  // profile/login hints localHome() in cli.ts prepends to HOME_HELP; the
139
140
  // rest is HOME_HELP verbatim, so skill and home view cannot drift apart.
140
141
  export function skillHomeHints() {
141
- return ["npx -y @knowttl/mg-axi profile list", "npx -y @knowttl/mg-axi login --help", ...HOME_HELP];
142
+ return ["npx -y @knowttl/mg-axi profile list", "npx -y @knowttl/mg-axi auth login --help", ...HOME_HELP];
142
143
  }
143
144
  // The full installable skill. Durable guidance only: read-only by default,
144
145
  // writes need preview plus typed confirmation, no secrets in output, the
@@ -165,15 +166,16 @@ export function skillDocument() {
165
166
  "Run commands non-interactively as `npx -y @knowttl/mg-axi ...`: no global install needed and no interactive prompts.",
166
167
  "Version pinning is the installer's choice: use `npx -y @knowttl/mg-axi@<version> ...` to select a specific release.",
167
168
  "Run `npx -y @knowttl/mg-axi doctor` first.",
168
- "See [README.md](../../README.md#release) for doctor profile selection, checks and failure behavior.",
169
+ "See [README.md](../../README.md#usage) for doctor profile selection, checks and failure behavior.",
169
170
  "",
170
171
  "## Orientation",
171
172
  "",
172
173
  "The exact current leaf registry is `src/catalogue.ts`.",
173
174
  "Its capability label is `native` (implemented by an mg-axi handler).",
174
- "Its effect is `read` for Graph read leaves and the doctor health check, `write` for the gated named writes, or `local` for home, profile, login and setup views.",
175
+ "Its effect is `read` for Graph read leaves and the doctor health check, `write` for the gated named writes, or `local` for home, profile, auth, config path, login-alias and setup views.",
175
176
  "Doctor acquires credentials silently and contacts Graph; only its help view stays offline.",
176
177
  "The list below records current executable leaves; it makes no coverage claim for other Graph operations.",
178
+ UNSUPPORTED_READ_NOTE,
177
179
  "See `docs/coverage.md` for the per-operation disposition records.",
178
180
  "`docs/coverage.md` and this file are generated from the same catalogue and inventory sources; `node tools/generate-docs.mjs --check` fails when they are stale.",
179
181
  "",
@@ -194,18 +196,18 @@ export function skillDocument() {
194
196
  "",
195
197
  "## Setup (explicit only)",
196
198
  "",
197
- "Follow [release guidance](../../README.md#release) for npm installation and explicit skill installation; [README.md](../../README.md#mg-axi) also documents checkout builds.",
199
+ "Follow [installation guidance](../../README.md#quick-start) for npm installation and explicit skill installation; [README.md](../../README.md#development--testing) also documents checkout builds.",
198
200
  "Create profiles explicitly and keep secrets out of argv and config files:",
199
201
  "",
200
202
  "```sh",
201
203
  "npx -y @knowttl/mg-axi setup # build steps, config path and capabilities; writes nothing",
202
204
  "npx -y @knowttl/mg-axi profile create --name soc --tenant <tenant-id> --client <client-id> --cloud commercial",
203
- "npx -y @knowttl/mg-axi login --profile soc --scopes https://graph.microsoft.com/User.Read.All",
205
+ "npx -y @knowttl/mg-axi auth login --profile soc --scopes https://graph.microsoft.com/User.Read.All",
204
206
  "npx -y @knowttl/mg-axi doctor # one bounded read per selected profile",
205
207
  "npx -y @knowttl/mg-axi entra user list --profile soc --limit 10",
206
208
  "```",
207
209
  "",
208
- "Configuration defaults to `~/.mg-axi/config.json`; `MG_AXI_CONFIG` selects a separate file.",
210
+ "Configuration uses the XDG config directory (default `~/.config/mg-axi/config.json`), or `%APPDATA%\\mg-axi\\config.json` on Windows; `--config` overrides `MG_AXI_CONFIG`.",
209
211
  "`mg-axi setup` shows the selected path and writes nothing.",
210
212
  "Application profiles authenticate headlessly through credential references: the profile names an env var or file and the environment supplies the value at acquisition time.",
211
213
  "Container examples for client secrets, certificate keys and workload federation live in [README.md](../../README.md#mg-axi).",
@@ -227,14 +229,13 @@ export function skillDocument() {
227
229
  "## Selecting a profile",
228
230
  "",
229
231
  "Read leaves accept `--profile <name>`.",
230
- "Selection uses `--profile` or the configured default.",
231
- "Without either, read leaves report `AUTH_REQUIRED`, even if only one profile exists.",
232
- "Doctor selection is documented in [README.md](../../README.md#release).",
232
+ "Profile selection order and env-profile rules live in [README Configuration](../../README.md#configuration); an explicitly selected file profile ignores env fields.",
233
+ "Doctor selection is documented in [README.md](../../README.md#usage).",
233
234
  "Without profiles, home, setup and profile list show unconfigured state; Graph read leaves report `AUTH_REQUIRED`.",
234
235
  "",
235
236
  "## Safety",
236
237
  "",
237
- "Reads run through a session that authorizes only catalogued operations before silent credential acquisition; only explicit `login` opens a browser or prints a device-code challenge.",
238
+ "Reads run through a session that authorizes only catalogued operations before silent credential acquisition; only explicit `auth login` opens a browser or prints a device-code challenge.",
238
239
  "Writes preview before sending and need `--execute` plus a typed `--confirm` repeating the target; an already-desired value is a no-op with exit 0, never an error.",
239
240
  "Output is TOON on stdout; diagnostics use stderr.",
240
241
  "Text truncates at 500 characters (`--full` restores text, never redaction); row caps resume through opaque cursors.",
@@ -407,7 +408,7 @@ export function capabilityDocument() {
407
408
  "",
408
409
  `- implemented read leaves: ${readCount} (${readLeaves.length} named Entra reads plus reviewed raw api get and doctor health check)`,
409
410
  `- implemented write leaves: ${writeLeaves.length} (named gated mutations below)`,
410
- `- local leaves: ${localCount} (home, profile, login and setup views)`,
411
+ `- local leaves: ${localCount} (home, profile, config, auth, login alias and setup views)`,
411
412
  ...DISPOSITIONS.map(disposition => `- inventory ${disposition}: ${count(disposition)}`),
412
413
  "",
413
414
  ...auditRecord(rows),
@@ -420,9 +421,12 @@ export function capabilityDocument() {
420
421
  "| `mg-axi api get` | reviewed raw reads (see src/api.ts) | reviewed-raw-read catalogue | API-01 |",
421
422
  "| `mg-axi doctor` | bounded `GET:/users` health check | uses the named user-list read | PACK-01 |",
422
423
  "",
424
+ UNSUPPORTED_READ_NOTE,
425
+ "",
423
426
  "## EXT-01 domain scope decisions",
424
427
  "",
425
428
  "Firstmate decision R1: approve narrowing this change to the eight v1.0 domain reads above.",
429
+ "The root domain-dns-record list is now explicitly unsupported; use verification-dns-record or service-configuration-record list with a parent domain.",
426
430
  "The v1.0 operations below carry the deferred disposition (out of v1) and the beta operations remain scheduled (never agreed for v1); no new commands or raw access are approved.",
427
431
  "Federation stays out because it can carry signing-certificate material.",
428
432
  "Firstmate decision R2: keep the write-family status section as the coverage fix added to scope (resolved-kept).",
@@ -561,10 +565,10 @@ export function capabilityDocument() {
561
565
  "",
562
566
  "## EXT-01 directory-objects scope decisions",
563
567
  "",
564
- "This change covers the three v1.0 directory-object reads above (directory-object list, show and count).",
568
+ "Directory-object show and count are available. Graph v1.0 rejects directory-object list; the legacy command fails locally and points to show or a typed list command.",
565
569
  "The v1.0 operations below carry the deferred disposition (out of v1) and the beta operations remain scheduled (never agreed for v1); no new commands or raw access are approved.",
566
570
  "Directory-object reads support v1.0 only; beta needs its own review.",
567
- "Delta-token sync needs its own change-tracking contract beyond the approved list/show/count reads; the POST lookup and validation actions need their own request and projection review.",
571
+ "Delta-token sync needs its own change-tracking contract beyond the approved show/count reads; the POST lookup and validation actions need their own request and projection review.",
568
572
  "Rows are polymorphic: only the base-type properties plus the @odata.type discriminator are projected, so subtype secrets or credentials can never appear.",
569
573
  "",
570
574
  "| Inventory operation | Disposition | Owning slice | Deferral reason |",
package/dist/doctor.js CHANGED
@@ -1,6 +1,7 @@
1
1
  import { AxiError } from "axi-sdk-js";
2
2
  import { LEAVES, operationFor } from "./catalogue.js";
3
3
  import { listUsers } from "./entra-users.js";
4
+ import { authStatus } from "./auth-status.js";
4
5
  // PACK-01: explicit release health check over the reviewed read-only session.
5
6
  // Doctor performs one documented bounded read per profile (entra user list
6
7
  // with a one-row window) and reports configuration, connectivity,
@@ -8,7 +9,7 @@ import { listUsers } from "./entra-users.js";
8
9
  // DelegatedAuth.credential and ApplicationAuth.credential never open a
9
10
  // browser or device-code flow, so doctor never signs in interactively.
10
11
  // Doctor never auto-installs, changes profiles or enables Graph writes;
11
- // silent token refresh may update the protected credential cache.
12
+ // silent token refresh stays in memory.
12
13
  // See README.md for usage.
13
14
  export const DOCTOR_OPERATION = "GET:/users";
14
15
  export const DOCTOR_VERSION = "v1.0";
@@ -16,19 +17,18 @@ export const DOCTOR_CHECK = `entra user list --limit 1 (${DOCTOR_VERSION}:${DOCT
16
17
  export function doctorTargets(store, flag) {
17
18
  if (flag !== undefined)
18
19
  return [store.resolve(flag).name];
19
- const items = store.list();
20
- if (!items.length)
21
- return [];
22
20
  try {
23
21
  return [store.resolve(undefined).name];
24
22
  }
25
- catch {
26
- return items.map(item => item.name);
23
+ catch (error) {
24
+ if (!(error instanceof AxiError) || !["PROFILE_REQUIRED", "PROFILE_AMBIGUOUS"].includes(error.code))
25
+ throw error;
26
+ return store.list().map(item => item.name);
27
27
  }
28
28
  }
29
29
  export async function runDoctor(args) {
30
30
  if (!args.names.length) {
31
- throw new AxiError("No profiles are configured", "AUTH_REQUIRED", ["npx -y @knowttl/mg-axi profile create --help", "npx -y @knowttl/mg-axi setup"]);
31
+ throw new AxiError("No profiles are configured", "PROFILE_REQUIRED", ["npx -y @knowttl/mg-axi profile create --help", "npx -y @knowttl/mg-axi setup"]);
32
32
  }
33
33
  const leaf = LEAVES.find(item => item.path === "entra user list");
34
34
  const operation = leaf === undefined ? undefined : operationFor(leaf, DOCTOR_VERSION);
@@ -39,7 +39,9 @@ export async function runDoctor(args) {
39
39
  for (const name of args.names) {
40
40
  const rerun = `npx -y @knowttl/mg-axi doctor --profile ${name}`;
41
41
  try {
42
- const selected = args.store.resolve(name);
42
+ args.store.verifyPermissions();
43
+ const selected = args.selected ?? args.store.resolve(name);
44
+ authStatus(name, selected.profile);
43
45
  // The same bounded read the named leaf serves: one fetched page, one
44
46
  // surfaced row, remainder buffered in a discarded cursor. Doctor keeps
45
47
  // no cursor and accepts none; recovery reruns doctor, not the read.
@@ -72,7 +72,7 @@ export const KNOWN_PROVISIONING_FIELDS = [
72
72
  "jobId",
73
73
  "cycleId",
74
74
  "changeId",
75
- "action",
75
+ "provisioningAction",
76
76
  "durationInMilliseconds",
77
77
  "sourceSystem",
78
78
  "sourceIdentity",
@@ -88,7 +88,7 @@ const PROVISIONING_KNOWN = new Set(KNOWN_PROVISIONING_FIELDS);
88
88
  // Compact list rows: identifier, time, who/what happened, and outcome.
89
89
  const DEFAULT_SIGNIN_LIST_SELECT = ["id", "createdDateTime", "userPrincipalName", "appDisplayName"];
90
90
  const DEFAULT_AUDIT_LIST_SELECT = ["id", "activityDateTime", "activityDisplayName", "result"];
91
- const DEFAULT_PROVISIONING_LIST_SELECT = ["id", "activityDateTime", "action", "provisioningStatusInfo"];
91
+ const DEFAULT_PROVISIONING_LIST_SELECT = ["id", "activityDateTime", "provisioningAction", "provisioningStatusInfo"];
92
92
  // Show rows: the full reviewed server set for one object.
93
93
  const DEFAULT_SIGNIN_SHOW_SELECT = [...KNOWN_SIGNIN_FIELDS];
94
94
  const DEFAULT_AUDIT_SHOW_SELECT = [...KNOWN_AUDIT_FIELDS];
@@ -344,7 +344,8 @@ async function listLogs(shape, session, flags, profile, operation, help, profile
344
344
  const scopes = scopesFor(flags, profile, help, shape.defaultScopes);
345
345
  const full = flags.full === true;
346
346
  const filter = boundedFilter(shape.dateField, flags, help);
347
- const query = { $select: select.join(",") };
347
+ // Provisioning lists reject $select; selection remains a local projection.
348
+ const query = shape.noun === "provisioning" ? {} : { $select: select.join(",") };
348
349
  const explicitFilter = filter !== undefined;
349
350
  if (explicitFilter)
350
351
  query.$filter = filter;
@@ -362,7 +363,7 @@ async function listLogs(shape, session, flags, profile, operation, help, profile
362
363
  // The hint replays the caller's own time flags (or the cursor alone, which
363
364
  // restores the saved query); copying back the composed $filter would wrap
364
365
  // it a second time and conflict with the cursor context.
365
- const effectiveFlags = { ...flags, select: result.query.$select ?? shape.defaultSelect.join(",") };
366
+ const effectiveFlags = { ...flags, select: result.query.$select ?? select.join(",") };
366
367
  const rows = [];
367
368
  let truncated = false;
368
369
  for (const row of result.value) {
@@ -376,7 +376,8 @@ async function listCollection(shape, session, flags, profile, operation, help, p
376
376
  const { select, fields } = selectedFields(flags, savedSelect === undefined ? shape.defaultSelect : fieldList(savedSelect, "select", shape.known, shape.knownList, shape.noun, help), shape.known, shape.knownList, shape.noun, help);
377
377
  const scopes = scopesFor(flags, profile, help, shape.defaultScopes);
378
378
  const full = flags.full === true;
379
- const query = { $select: select.join(",") };
379
+ // Authentication contexts reject $select; selection remains a local projection.
380
+ const query = shape.noun === "auth-context" ? {} : { $select: select.join(",") };
380
381
  if (flags.filter !== undefined)
381
382
  query.$filter = String(flags.filter);
382
383
  const args = { profile, operation, query, scopes };
@@ -397,7 +398,7 @@ async function listCollection(shape, session, flags, profile, operation, help, p
397
398
  args.limit = flags.limit === undefined ? 100 : Number(flags.limit);
398
399
  }
399
400
  const result = await withGuidance(shape.denialHints, () => session.collect(args));
400
- const effectiveFlags = { ...flags, select: result.query.$select ?? shape.defaultSelect.join(",") };
401
+ const effectiveFlags = { ...flags, select: result.query.$select ?? select.join(",") };
401
402
  if (result.query.$filter !== undefined)
402
403
  effectiveFlags.filter = result.query.$filter;
403
404
  const rows = [];
@@ -474,7 +475,7 @@ async function showSingle(shape, session, flags, profile, operation, help, profi
474
475
  profile,
475
476
  operation,
476
477
  params,
477
- query: { $select: select.join(",") },
478
+ query: shape.noun === "auth-strength-policy" ? {} : { $select: select.join(",") },
478
479
  scopes,
479
480
  }));
480
481
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
@@ -1,7 +1,7 @@
1
1
  import { AxiError } from "axi-sdk-js";
2
2
  import { listTotals } from "./list-totals.js";
3
3
  // EXT-01 directory-objects subfamily: the read mapping behind
4
- // `mg-axi entra directory-object list/show/count`. Operation construction
4
+ // `mg-axi entra directory-object show/count`. Operation construction
5
5
  // stays beside its command; the shared session owns URLs, credentials,
6
6
  // paging, retries and error translation, and the SDK owns TOON rendering.
7
7
  // This module only maps flags to session calls and projects rows for compact
@@ -10,10 +10,9 @@ import { listTotals } from "./list-totals.js";
10
10
  // Reviewed against the v1.0 directoryobject-get operation documentation and
11
11
  // the directoryObject resource reference on 2026-10-05. There is no dedicated
12
12
  // List operation page: the resource Methods table documents Get, delta,
13
- // Delete and the lookup/validation functions only, so the collection GET
14
- // follows the sibling get contract and the resource reference (both name
15
- // Directory.Read.All), with $select-only queries because collection filter
16
- // support is unreviewed. Both reads take delegated or application
13
+ // Delete and the lookup/validation functions only. Graph rejects the root
14
+ // collection GET, so the session refuses the legacy list command with
15
+ // supported alternatives. Detail and count reads take delegated or application
17
16
  // Directory.Read.All, the documented least privilege in each mode; personal
18
17
  // Microsoft accounts are not supported. No delegated role and no P1/P2
19
18
  // prerequisite are stated for these reads. The $count scalar carries no
@@ -2,7 +2,7 @@ import { AxiError } from "axi-sdk-js";
2
2
  import { listTotals } from "./list-totals.js";
3
3
  // EXT-01 domains subfamily: the read mapping behind `mg-axi entra domain
4
4
  // list/show`, the per-domain verification and service-configuration DNS
5
- // record reads, and the top-level domain DNS record reads. Operation
5
+ // record reads, and the top-level domain DNS record show. Operation
6
6
  // construction stays beside its command; the shared session owns URLs,
7
7
  // credentials, paging, retries and error translation, and the SDK owns TOON
8
8
  // rendering. This module only maps flags to session calls and projects rows
@@ -17,7 +17,7 @@ import { listTotals } from "./list-totals.js";
17
17
  // domain list/get support those plus Directory Readers and other directory
18
18
  // roles). Personal Microsoft accounts are not supported. No P1/P2
19
19
  // prerequisite is stated for these reads. The single-record GETs and the
20
- // top-level /domainDnsRecords GETs carry no operation-level documentation
20
+ // top-level /domainDnsRecords/{id} GET carries no operation-level documentation
21
21
  // page; they read the same domainDnsRecord resource through the same
22
22
  // permission contract, so the review cites the parent list documentation
23
23
  // alongside the domainDnsRecord resource reference. Domain ids are fully
@@ -28,6 +28,8 @@ import { listTotals } from "./list-totals.js";
28
28
  // no $count or ConsistencyLevel contract. No domain mutation exists in this
29
29
  // slice: verify, forceDelete, promote, federation and shared-email surfaces
30
30
  // belong to later pieces.
31
+ // Graph rejects the root /domainDnsRecords collection. The session refuses
32
+ // its legacy list command and points to the parent-domain DNS collections.
31
33
  // Every domain property this slice may request or display, matching the
32
34
  // reviewed raw surface. Anything else fails before credentials.
33
35
  export const KNOWN_DOMAIN_FIELDS = [
@@ -186,7 +186,8 @@ function collectionCommon(session, flags, known, knownList, defaults, defaultSco
186
186
  return { cursor, select, fields, scopes: scopesFor(flags, defaultScopes, profile, help), full: flags.full === true, filter };
187
187
  }
188
188
  function collectArgs(profile, operation, common, flags, help) {
189
- const query = { $select: common.select.join(",") };
189
+ // Lifecycle-policy lists reject $select; keep the selected output fields local.
190
+ const query = operation.path === "/groupLifecyclePolicies" ? {} : { $select: common.select.join(",") };
190
191
  if (common.filter !== undefined)
191
192
  query.$filter = common.filter;
192
193
  const args = { profile, operation, query, scopes: common.scopes };
@@ -227,7 +228,7 @@ function singleResult(raw, fields, full, kind) {
227
228
  export async function listLifecyclePolicies(session, flags, profile, operation, help, profileName) {
228
229
  const common = collectionCommon(session, flags, KNOWN_LIFECYCLE, KNOWN_LIFECYCLE_FIELDS, DEFAULT_LIFECYCLE_LIST_SELECT, DEFAULT_LIFECYCLE_SCOPES, operation, help, profile, true);
229
230
  const result = await withGuidance(LIFECYCLE_DENIAL_HINTS, () => session.collect(collectArgs(profile, operation, common, flags, help)));
230
- const effectiveFlags = { ...flags, select: result.query.$select ?? DEFAULT_LIFECYCLE_LIST_SELECT.join(",") };
231
+ const effectiveFlags = { ...flags, select: result.query.$select ?? common.select.join(",") };
231
232
  if (result.query.$filter !== undefined)
232
233
  effectiveFlags.filter = result.query.$filter;
233
234
  const policies = [];
@@ -3,8 +3,8 @@ import { listTotals } from "./list-totals.js";
3
3
  // READ-09: the Entra directory-role and PIM read mapping behind
4
4
  // `mg-axi entra directory-role list/show`, `mg-axi entra directory-role-template
5
5
  // list/show`, `mg-axi entra directory-role member list`, `mg-axi entra
6
- // directory-role scoped-member list/show`, `mg-axi entra scoped-role-membership
7
- // list/show`, `mg-axi entra role-assignment list`, `mg-axi entra pim eligible
6
+ // directory-role scoped-member list/show`, `mg-axi entra role-assignment
7
+ // list`, `mg-axi entra pim eligible
8
8
  // list` and `mg-axi entra pim active list`.
9
9
  // Operation construction stays beside its command; the shared session owns
10
10
  // URLs, credentials, paging, retries and error translation, and the SDK owns
@@ -44,6 +44,8 @@ import { listTotals } from "./list-totals.js";
44
44
  // every other PIM mutation belongs to later write slices, never to these
45
45
  // reads. Built-in roles are base inventory; custom role assignments need P1;
46
46
  // PIM reads need P2 or ID Governance.
47
+ // Direct /scopedRoleMemberships queries are unsupported by Graph v1.0;
48
+ // the session refuses the legacy commands with parent-role alternatives.
47
49
  // Eligible and active instances need separate reviewed property sets:
48
50
  // only active instances expose assignmentType.
49
51
  export const KNOWN_ROLE_FIELDS = [
@@ -84,9 +86,8 @@ export const KNOWN_ROLE_MEMBER_FIELDS = [
84
86
  ];
85
87
  export const KNOWN_SCOPED_MEMBER_FIELDS = [
86
88
  "id",
87
- "principalId",
89
+ "roleMemberInfo",
88
90
  "roleId",
89
- "directoryScopeId",
90
91
  "administrativeUnitId",
91
92
  ];
92
93
  const ROLE_KNOWN = new Set(KNOWN_ROLE_FIELDS);
@@ -106,7 +107,7 @@ const DEFAULT_ACTIVE_SELECT = ["id", "principalId", "roleDefinitionId", "assignm
106
107
  const DEFAULT_TEMPLATE_LIST_SELECT = ["id", "displayName", "description"];
107
108
  const DEFAULT_TEMPLATE_SHOW_SELECT = [...KNOWN_TEMPLATE_FIELDS];
108
109
  const DEFAULT_MEMBER_SELECT = ["id", "displayName"];
109
- const DEFAULT_SCOPED_LIST_SELECT = ["id", "principalId", "roleId", "directoryScopeId"];
110
+ const DEFAULT_SCOPED_LIST_SELECT = ["id", "roleMemberInfo", "roleId", "administrativeUnitId"];
110
111
  const DEFAULT_SCOPED_SHOW_SELECT = [...KNOWN_SCOPED_MEMBER_FIELDS];
111
112
  export const DEFAULT_ROLE_SCOPES = ["https://graph.microsoft.com/RoleManagement.Read.Directory"];
112
113
  export const DEFAULT_ELIGIBLE_SCOPES = ["https://graph.microsoft.com/RoleEligibilitySchedule.Read.Directory"];