@knowttl/mg-axi 0.1.4 → 0.1.6

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/profiles.js CHANGED
@@ -1,104 +1,161 @@
1
- import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
2
- import { randomUUID } from "node:crypto";
1
+ import { existsSync } from "node:fs";
3
2
  import { homedir } from "node:os";
4
- import { dirname, join } from "node:path";
3
+ import { dirname, join, resolve } from "node:path";
5
4
  import { AxiError } from "axi-sdk-js";
5
+ import { configPath, toolDirectory } from "./paths.js";
6
+ import { PrivateFiles } from "./private-file.js";
7
+ import { validateSecretRef } from "./secrets.js";
8
+ const names = new WeakMap();
6
9
  const guid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
7
- const namePattern = /^[a-zA-Z0-9][a-zA-Z0-9_-]{0,63}$/;
10
+ const namePattern = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/;
8
11
  const object = (value) => !!value && typeof value === "object" && !Array.isArray(value);
9
12
  const exact = (value, keys) => Object.keys(value).every(key => keys.includes(key));
10
13
  function invalid(message) {
11
- throw new AxiError(message, "VALIDATION_ERROR", ["mg-axi profile create --help", "Correct the version-1 profile configuration; no automatic migration is performed"]);
14
+ throw new AxiError(message, "CONFIG_INVALID", ["mg-axi profile create --help", "Correct the version-1 profile configuration; no automatic migration is performed"]);
12
15
  }
13
- const thumbprint = /^[0-9a-f]{40}$/i;
14
- const envName = /^[A-Za-z_][A-Za-z0-9_]*$/;
15
- const credentialPath = (value) => typeof value === "string" && !!value.trim();
16
- const exactlyOne = (values) => values.filter(value => value !== undefined).length === 1;
17
- const sharedKeys = ["mode", "tenantId", "clientId", "cloud", "enabledPacks", "preview", "sensitiveAreas", "credentialRef", "allowDeviceCode", "writes"];
18
- const MAX_WRITE_OPERATIONS = 64;
19
- const MAX_WRITE_OPERATION_LENGTH = 256;
20
- function validateWrites(value) {
21
- if (value === undefined)
22
- return undefined;
23
- if (!object(value) || !exact(value, ["allowWrites", "operations"]) || typeof value.allowWrites !== "boolean" ||
24
- !Array.isArray(value.operations) || value.operations.length === 0 || value.operations.length > MAX_WRITE_OPERATIONS ||
25
- value.operations.some(entry => typeof entry !== "string" || !entry.length || entry.length > MAX_WRITE_OPERATION_LENGTH)) {
26
- invalid("Invalid write policy; hand-edit a writes object with boolean allowWrites and a nonempty operations array of nonempty operation names");
27
- }
28
- return Object.freeze({ allowWrites: value.allowWrites, operations: Object.freeze([...value.operations]) });
16
+ export function booleanEnv(name, env = process.env) {
17
+ const value = env[name]?.toLowerCase();
18
+ if (value === undefined || value === "0" || value === "false")
19
+ return false;
20
+ if (value === "1" || value === "true")
21
+ return true;
22
+ return invalid(`Invalid boolean for ${name}; use 1, true, 0 or false`);
29
23
  }
30
- const sharedIdentity = (value) => value.cloud === "commercial" && typeof value.tenantId === "string" && guid.test(value.tenantId) &&
31
- typeof value.clientId === "string" && guid.test(value.clientId) && typeof value.preview === "boolean" &&
32
- Array.isArray(value.enabledPacks) && value.enabledPacks.every(pack => pack === "entra") && new Set(value.enabledPacks).size === value.enabledPacks.length &&
33
- Array.isArray(value.sensitiveAreas) && value.sensitiveAreas.length === 0;
34
- export function validateDelegatedProfile(value) {
35
- if (!object(value) || !exact(value, sharedKeys) || value.mode !== "delegated" || !sharedIdentity(value) || typeof value.allowDeviceCode !== "boolean" ||
36
- !object(value.credentialRef) || !exact(value.credentialRef, ["provider", "key"]) || value.credentialRef.provider !== "os-or-session" ||
37
- typeof value.credentialRef.key !== "string" || !guid.test(value.credentialRef.key))
38
- invalid("Invalid delegated profile; explicit tenant/client IDs and commercial cloud are required, with credentials only by reference");
39
- const profile = value;
40
- const writes = validateWrites(value.writes);
41
- return Object.freeze({ mode: "delegated", tenantId: profile.tenantId, clientId: profile.clientId, cloud: "commercial", preview: profile.preview,
42
- allowDeviceCode: profile.allowDeviceCode, enabledPacks: Object.freeze([...profile.enabledPacks]), sensitiveAreas: Object.freeze([]),
43
- credentialRef: Object.freeze({ provider: "os-or-session", key: profile.credentialRef.key }), ...(writes === undefined ? {} : { writes }) });
24
+ // Names belong to the selected configuration, never to its serialized profile schema.
25
+ export function profileName(profile) { return names.get(profile); }
26
+ function validated(source, profile) {
27
+ const name = names.get(source);
28
+ if (name !== undefined)
29
+ names.set(profile, name);
30
+ return Object.freeze(profile);
44
31
  }
45
- export function validateApplicationProfile(value) {
46
- if (!object(value) || !exact(value, sharedKeys) || value.mode !== "application" || !sharedIdentity(value) || value.allowDeviceCode !== false ||
47
- !object(value.credentialRef))
48
- invalid("Invalid application profile; explicit tenant/client IDs and commercial cloud are required, with credentials only by reference");
49
- const profile = value;
50
- const ref = profile.credentialRef;
51
- const writes = validateWrites(value.writes);
52
- const writeFields = writes === undefined ? {} : { writes };
53
- if (ref.provider === "certificate" && exact(ref, ["provider", "key", "thumbprint", "keyFile", "keyEnv"]) && typeof ref.key === "string" && guid.test(ref.key) &&
54
- typeof ref.thumbprint === "string" && thumbprint.test(ref.thumbprint) && ref.keyFile === undefined && ref.keyEnv === undefined) {
55
- return Object.freeze({ mode: "application", tenantId: profile.tenantId, clientId: profile.clientId, cloud: "commercial",
56
- preview: profile.preview, allowDeviceCode: false, enabledPacks: Object.freeze([...profile.enabledPacks]), sensitiveAreas: Object.freeze([]),
57
- credentialRef: Object.freeze({ provider: "certificate", key: ref.key, thumbprint: ref.thumbprint.toLowerCase() }), ...writeFields });
32
+ function shared(value) {
33
+ if (typeof value.tenantId !== "string" || !guid.test(value.tenantId) || typeof value.clientId !== "string" || !guid.test(value.clientId))
34
+ invalid("Explicit tenant and client UUIDs are required");
35
+ if (value.cloud !== undefined && value.cloud !== "commercial")
36
+ invalid("Only commercial cloud is supported");
37
+ const packs = value.enabledPacks === undefined ? ["entra"] : value.enabledPacks;
38
+ const sensitive = value.sensitiveAreas === undefined ? [] : value.sensitiveAreas;
39
+ const preview = value.preview === undefined ? false : value.preview;
40
+ if (!Array.isArray(packs) || packs.some(pack => pack !== "entra") || new Set(packs).size !== packs.length ||
41
+ !Array.isArray(sensitive) || sensitive.length || typeof preview !== "boolean")
42
+ invalid("Invalid profile policy");
43
+ let writes;
44
+ if (value.writes !== undefined) {
45
+ const policy = value.writes;
46
+ if (!object(policy) || !exact(policy, ["allowWrites", "operations"]) || typeof policy.allowWrites !== "boolean" ||
47
+ !Array.isArray(policy.operations) || !policy.operations.length || policy.operations.length > 64 ||
48
+ policy.operations.some(entry => typeof entry !== "string" || !entry.length || entry.length > 256))
49
+ invalid("Invalid write policy; use boolean allowWrites and a nonempty operations array");
50
+ writes = Object.freeze({ allowWrites: policy.allowWrites, operations: Object.freeze([...policy.operations]) });
58
51
  }
59
- if (ref.provider === "certificate" && exact(ref, ["provider", "key", "thumbprint", "keyFile", "keyEnv"]) && typeof ref.key === "string" && guid.test(ref.key) &&
60
- typeof ref.thumbprint === "string" && thumbprint.test(ref.thumbprint) && exactlyOne([ref.keyFile, ref.keyEnv]) &&
61
- (ref.keyFile === undefined || credentialPath(ref.keyFile)) && (ref.keyEnv === undefined || (typeof ref.keyEnv === "string" && envName.test(ref.keyEnv)))) {
62
- const keySource = ref.keyFile === undefined ? { keyEnv: ref.keyEnv } : { keyFile: ref.keyFile };
63
- return Object.freeze({ mode: "application", tenantId: profile.tenantId, clientId: profile.clientId, cloud: "commercial",
64
- preview: profile.preview, allowDeviceCode: false, enabledPacks: Object.freeze([...profile.enabledPacks]), sensitiveAreas: Object.freeze([]),
65
- credentialRef: Object.freeze({ provider: "certificate", key: ref.key, thumbprint: ref.thumbprint.toLowerCase(), ...keySource }), ...writeFields });
66
- }
67
- if (ref.provider === "client-secret" && exact(ref, ["provider", "key", "secretEnv", "secretFile"]) && typeof ref.key === "string" && guid.test(ref.key) &&
68
- exactlyOne([ref.secretEnv, ref.secretFile]) &&
69
- (ref.secretEnv === undefined || (typeof ref.secretEnv === "string" && envName.test(ref.secretEnv))) && (ref.secretFile === undefined || credentialPath(ref.secretFile))) {
70
- const secretSource = ref.secretEnv === undefined ? { secretFile: ref.secretFile } : { secretEnv: ref.secretEnv };
71
- return Object.freeze({ mode: "application", tenantId: profile.tenantId, clientId: profile.clientId, cloud: "commercial",
72
- preview: profile.preview, allowDeviceCode: false, enabledPacks: Object.freeze([...profile.enabledPacks]), sensitiveAreas: Object.freeze([]),
73
- credentialRef: Object.freeze({ provider: "client-secret", key: ref.key, ...secretSource }), ...writeFields });
52
+ return { tenantId: value.tenantId, clientId: value.clientId, cloud: "commercial", enabledPacks: Object.freeze([...packs]),
53
+ preview: preview, sensitiveAreas: Object.freeze([]), ...(writes === undefined ? {} : { writes }) };
54
+ }
55
+ const sharedKeys = ["mode", "tenantId", "clientId", "cloud", "enabledPacks", "preview", "sensitiveAreas", "allowDeviceCode", "writes"];
56
+ export function validateDelegatedProfile(value, files) {
57
+ if (!object(value) || value.mode !== "delegated" || !exact(value, [...sharedKeys, "token"]) ||
58
+ (value.allowDeviceCode !== undefined && typeof value.allowDeviceCode !== "boolean"))
59
+ invalid("Invalid delegated profile; token must be a SecretRef");
60
+ return validated(value, { ...shared(value), mode: "delegated", allowDeviceCode: value.allowDeviceCode === true, token: validateSecretRef(value.token, "token", true, files) });
61
+ }
62
+ export function validateApplicationProfile(value, files) {
63
+ if (!object(value) || value.mode !== "application" || (value.allowDeviceCode !== undefined && value.allowDeviceCode !== false))
64
+ invalid("Invalid application profile; device code is unavailable");
65
+ const keys = value.auth === "certificate" ? ["privateKey", "thumbprintSha256"] : value.auth === "client-secret" ? ["clientSecret"] : value.auth === "federated" ? ["token"] : [];
66
+ if (!keys.length || !exact(value, [...sharedKeys, "auth", ...keys]))
67
+ invalid("Invalid application profile; auth must be certificate, client-secret or federated with only its credential fields");
68
+ const base = { ...shared(value), mode: "application", allowDeviceCode: false };
69
+ if (value.auth === "certificate") {
70
+ if (typeof value.thumbprintSha256 !== "string" || !/^[0-9a-f]{64}$/i.test(value.thumbprintSha256))
71
+ invalid("thumbprintSha256 must contain 64 hex digits");
72
+ return validated(value, { ...base, auth: "certificate", thumbprintSha256: value.thumbprintSha256.toLowerCase(), privateKey: validateSecretRef(value.privateKey, "privateKey", false, files) });
74
73
  }
75
- if (ref.provider === "federated" && exact(ref, ["provider", "key", "tokenFileEnv"]) && typeof ref.key === "string" && guid.test(ref.key) &&
76
- (ref.tokenFileEnv === undefined || (typeof ref.tokenFileEnv === "string" && envName.test(ref.tokenFileEnv)))) {
77
- return Object.freeze({ mode: "application", tenantId: profile.tenantId, clientId: profile.clientId, cloud: "commercial",
78
- preview: profile.preview, allowDeviceCode: false, enabledPacks: Object.freeze([...profile.enabledPacks]), sensitiveAreas: Object.freeze([]),
79
- credentialRef: Object.freeze({ provider: "federated", key: ref.key, ...(ref.tokenFileEnv === undefined ? {} : { tokenFileEnv: ref.tokenFileEnv }) }), ...writeFields });
74
+ if (value.auth === "client-secret")
75
+ return validated(value, { ...base, auth: "client-secret", clientSecret: validateSecretRef(value.clientSecret, "clientSecret", false, files) });
76
+ return validated(value, { ...base, auth: "federated", token: validateSecretRef(value.token, "token", false, files) });
77
+ }
78
+ export function validateProfile(value, files) {
79
+ return object(value) && value.mode === "application" ? validateApplicationProfile(value, files) : validateDelegatedProfile(value, files);
80
+ }
81
+ function envProfile(env) {
82
+ const fields = ["MODE", "TENANT_ID", "CLIENT_ID", "AUTH", "CLOUD", "PREVIEW", "ALLOW_DEVICE_CODE", "ENABLED_PACKS", "SENSITIVE_AREAS", "WRITES"];
83
+ if (!fields.some(field => env[`MG_AXI_${field}`] !== undefined))
84
+ return undefined;
85
+ if (!env.MG_AXI_MODE || !env.MG_AXI_TENANT_ID || !env.MG_AXI_CLIENT_ID)
86
+ invalid("Partial env profile; MG_AXI_MODE, MG_AXI_TENANT_ID and MG_AXI_CLIENT_ID are required");
87
+ const json = (name) => {
88
+ if (env[name] === undefined)
89
+ return undefined;
90
+ try {
91
+ return JSON.parse(env[name]);
92
+ }
93
+ catch {
94
+ return invalid(`Invalid JSON field ${name}`);
95
+ }
96
+ };
97
+ const base = { mode: env.MG_AXI_MODE, tenantId: env.MG_AXI_TENANT_ID, clientId: env.MG_AXI_CLIENT_ID, cloud: env.MG_AXI_CLOUD,
98
+ preview: booleanEnv("MG_AXI_PREVIEW", env), allowDeviceCode: booleanEnv("MG_AXI_ALLOW_DEVICE_CODE", env),
99
+ enabledPacks: json("MG_AXI_ENABLED_PACKS"), sensitiveAreas: json("MG_AXI_SENSITIVE_AREAS"), writes: json("MG_AXI_WRITES"),
100
+ ...(env.MG_AXI_AUTH === undefined ? {} : { auth: env.MG_AXI_AUTH }),
101
+ ...(env.MG_AXI_THUMBPRINT_SHA256 === undefined ? {} : { thumbprintSha256: env.MG_AXI_THUMBPRINT_SHA256 }),
102
+ ...(env.MG_AXI_CLIENT_SECRET === undefined ? {} : { clientSecret: { env: "MG_AXI_CLIENT_SECRET" } }),
103
+ ...(env.MG_AXI_PRIVATE_KEY === undefined ? {} : { privateKey: { env: "MG_AXI_PRIVATE_KEY" } }),
104
+ ...(env.MG_AXI_TOKEN === undefined ? {} : { token: { env: "MG_AXI_TOKEN" } }) };
105
+ if (base.mode === "delegated") {
106
+ if (!env.MG_AXI_TOKEN)
107
+ invalid("Partial env profile; MG_AXI_TOKEN must supply a delegated cache");
108
+ return validateDelegatedProfile(base);
80
109
  }
81
- invalid("Invalid application profile; credentialRef must be a certificate, client-secret or federated reference, never inlined key material");
110
+ const auth = env.MG_AXI_AUTH;
111
+ const required = auth === "certificate" ? ["PRIVATE_KEY", "THUMBPRINT_SHA256"] : auth === "client-secret" ? ["CLIENT_SECRET"] : auth === "federated" ? ["TOKEN"] : [];
112
+ if (!required.length || required.some(field => !env[`MG_AXI_${field}`]))
113
+ invalid("Partial env profile; MG_AXI_AUTH and its required credential fields must be set");
114
+ return validateApplicationProfile(base);
82
115
  }
83
- export function validateProfile(value) {
84
- if (!!value && typeof value === "object" && !Array.isArray(value) && value.mode === "application")
85
- return validateApplicationProfile(value);
86
- return validateDelegatedProfile(value);
116
+ export function movedError(kind, legacy, target, guidance) {
117
+ const quote = (path) => `'${path.replace(/'/g, "'\\''")}'`;
118
+ const move = process.platform === "win32"
119
+ ? `New-Item -ItemType Directory -Force -Path '${dirname(target).replace(/'/g, "''")}'; Move-Item -LiteralPath '${legacy.replace(/'/g, "''")}' -Destination '${target.replace(/'/g, "''")}'`
120
+ : `mkdir -p ${quote(dirname(target))} && mv ${quote(legacy)} ${quote(target)}`;
121
+ throw new AxiError(`${kind} moved from ${legacy} to ${target}`, "CONFIG_MOVED", [move, guidance]);
87
122
  }
88
123
  export class Profiles {
124
+ files;
89
125
  path;
90
- constructor(path = process.env.MG_AXI_CONFIG ?? join(homedir(), ".mg-axi", "config.json")) {
91
- this.path = path;
126
+ constructor(path, files = new PrivateFiles()) {
127
+ this.files = files;
128
+ this.path = path?.trim() ? path.trim() : configPath();
129
+ }
130
+ moved() {
131
+ const legacy = join(homedir(), ".mg-axi", "config.json");
132
+ const target = resolve(this.path) === resolve(legacy) ? join(toolDirectory("config"), "config.json") : this.path;
133
+ movedError("Configuration", legacy, target, "Update credentialRef to the flattened SecretRef format described in the AXI credential standard");
92
134
  }
93
135
  load() {
94
- let value;
136
+ const legacy = join(homedir(), ".mg-axi", "config.json");
137
+ if (resolve(this.path) === resolve(legacy) && existsSync(legacy))
138
+ this.moved();
139
+ let text;
95
140
  try {
96
- value = JSON.parse(readFileSync(this.path, "utf8"));
141
+ text = this.files.read(this.path, false);
97
142
  }
98
143
  catch (error) {
99
- if (error.code === "ENOENT")
144
+ if (error instanceof AxiError)
145
+ throw error;
146
+ if (error.code === "ENOENT") {
147
+ if (existsSync(legacy))
148
+ this.moved();
100
149
  return { version: 1, profiles: {} };
101
- invalid("Unable to read profile configuration; check the file permissions and version-1 JSON schema");
150
+ }
151
+ throw error;
152
+ }
153
+ let value;
154
+ try {
155
+ value = JSON.parse(text);
156
+ }
157
+ catch {
158
+ invalid("Unable to read profile configuration; check the version-1 JSON schema");
102
159
  }
103
160
  if (!object(value) || value.version !== 1 || !exact(value, ["version", "defaultProfile", "profiles"]) || !object(value.profiles))
104
161
  invalid("Unsupported profile configuration; explicitly migrate to version 1");
@@ -106,80 +163,69 @@ export class Profiles {
106
163
  for (const [name, profile] of Object.entries(value.profiles)) {
107
164
  if (!namePattern.test(name))
108
165
  invalid("Invalid profile name");
109
- profiles[name] = validateProfile(profile);
166
+ profiles[name] = validateProfile(profile, this.files);
167
+ names.set(profiles[name], name);
110
168
  }
111
169
  if (value.defaultProfile !== undefined && (typeof value.defaultProfile !== "string" || !Object.hasOwn(profiles, value.defaultProfile)))
112
170
  invalid("Default profile does not exist");
113
- if (new Set(Object.values(profiles).map(profile => profile.credentialRef.key)).size !== Object.keys(profiles).length)
114
- invalid("Each profile must have a separate credential reference");
115
171
  return { version: 1, profiles, defaultProfile: value.defaultProfile };
116
172
  }
117
173
  list() { return Object.entries(this.load().profiles).map(([name, profile]) => ({ name, mode: profile.mode, tenant: profile.tenantId, cloud: profile.cloud })); }
118
174
  resolve(name) {
175
+ const flag = name !== undefined && name.trim() !== "" ? name.trim() : undefined;
176
+ const envName = process.env.MG_AXI_PROFILE !== undefined && process.env.MG_AXI_PROFILE.trim() !== "" ? process.env.MG_AXI_PROFILE.trim() : undefined;
177
+ const explicit = flag ?? envName;
178
+ if (explicit === undefined) {
179
+ const profile = envProfile(process.env);
180
+ if (profile) {
181
+ names.set(profile, "env");
182
+ return { name: "env", profile };
183
+ }
184
+ }
119
185
  const config = this.load();
120
- const selected = name ?? config.defaultProfile;
121
- if (!selected || !Object.hasOwn(config.profiles, selected))
122
- throw new AxiError("No configured profile selected", "AUTH_REQUIRED", ["mg-axi profile list", "mg-axi profile create --help"]);
186
+ const configured = Object.keys(config.profiles);
187
+ const selected = explicit ?? config.defaultProfile ?? (configured.length === 1 ? configured[0] : undefined);
188
+ if (!selected)
189
+ throw new AxiError("No configured profile selected", configured.length ? "PROFILE_AMBIGUOUS" : "PROFILE_REQUIRED", ["mg-axi profile list", "mg-axi profile create --help"]);
190
+ if (!Object.hasOwn(config.profiles, selected))
191
+ throw new AxiError(`Profile ${selected} does not exist`, "PROFILE_NOT_FOUND", ["mg-axi profile list"]);
123
192
  return { name: selected, profile: config.profiles[selected] };
124
193
  }
194
+ verifyPermissions() {
195
+ try {
196
+ this.files.check(this.path, false, true);
197
+ }
198
+ catch (error) {
199
+ if (error.code !== "ENOENT")
200
+ throw error;
201
+ }
202
+ }
125
203
  create(name, tenantId, clientId, cloud, allowDeviceCode, application) {
126
204
  if (!namePattern.test(name))
127
205
  invalid("Profile name must contain only letters, digits, underscores or hyphens");
128
- const certificate = application?.certificateThumbprint;
129
- const federated = application?.federated ?? false;
130
- const secretEnv = application?.clientSecretEnv;
131
- const secretFile = application?.clientSecretFile;
132
- const keyFile = application?.certificateKeyFile;
133
- const keyEnv = application?.certificateKeyEnv;
134
- const tokenFileEnv = application?.federatedTokenFileEnv;
135
- if (application && (certificate !== undefined && (typeof certificate !== "string" || !/^[0-9a-f]{40}$/i.test(certificate))))
136
- invalid("Application certificate profiles name a 40-hex-digit thumbprint; the private key stays in referenced storage");
137
- for (const [label, value] of [["--client-secret-env", secretEnv], ["--certificate-key-env", keyEnv], ["--federated-token-file-env", tokenFileEnv]]) {
138
- if (application && value !== undefined && (typeof value !== "string" || !envName.test(value)))
139
- invalid(`Application profiles name the credential holder with ${label}; names match /^[A-Za-z_][A-Za-z0-9_]*$/`);
140
- }
141
- for (const [label, value] of [["--client-secret-file", secretFile], ["--certificate-key-file", keyFile]]) {
142
- if (application && value !== undefined && !credentialPath(value))
143
- invalid(`Application profiles name the credential holder with ${label}; paths must be nonempty`);
206
+ const base = { tenantId, clientId, cloud, allowDeviceCode };
207
+ let profile;
208
+ if (!application)
209
+ profile = validateDelegatedProfile({ ...base, mode: "delegated", token: { stored: true } }, this.files);
210
+ else {
211
+ const { certificateThumbprint, federated, clientSecretEnv, clientSecretFile, certificateKeyFile, certificateKeyEnv, federatedTokenFile } = application;
212
+ if (clientSecretEnv !== undefined && clientSecretFile !== undefined || certificateKeyEnv !== undefined && certificateKeyFile !== undefined)
213
+ invalid("Use exactly one env or file reference for each credential");
214
+ if ([certificateThumbprint !== undefined, federated === true, clientSecretEnv !== undefined || clientSecretFile !== undefined].filter(Boolean).length !== 1)
215
+ invalid("Select exactly one application credential kind");
216
+ if ((certificateKeyEnv !== undefined || certificateKeyFile !== undefined) && certificateThumbprint === undefined || federatedTokenFile !== undefined && !federated)
217
+ invalid("Credential holder does not match the selected application auth kind");
218
+ profile = validateApplicationProfile({ ...base, mode: "application",
219
+ ...(certificateThumbprint !== undefined ? { auth: "certificate", thumbprintSha256: certificateThumbprint, privateKey: certificateKeyFile !== undefined ? { file: certificateKeyFile } : { env: certificateKeyEnv ?? "MG_AXI_PRIVATE_KEY" } }
220
+ : federated ? { auth: "federated", ...(federatedTokenFile === undefined ? {} : { token: { file: federatedTokenFile } }) }
221
+ : { auth: "client-secret", clientSecret: clientSecretFile !== undefined ? { file: clientSecretFile } : { env: clientSecretEnv } }) }, this.files);
144
222
  }
145
- if (application && secretEnv !== undefined && secretFile !== undefined)
146
- invalid("Application client-secret profiles use exactly one holder: --client-secret-env or --client-secret-file");
147
- if (application && keyFile !== undefined && keyEnv !== undefined)
148
- invalid("Application certificate profiles use exactly one key holder: --certificate-key-file or --certificate-key-env; omit both for the OS keychain");
149
- if (application && (keyFile !== undefined || keyEnv !== undefined) && certificate === undefined)
150
- invalid("Certificate key holders need --certificate-thumbprint on the same profile");
151
- if (application && tokenFileEnv !== undefined && !federated)
152
- invalid("Federated token-file holders need --federated on the same profile");
153
- if (application && [certificate !== undefined, secretEnv !== undefined || secretFile !== undefined, federated].filter(Boolean).length !== 1)
154
- invalid("Application profiles use exactly one credential: --certificate-thumbprint, --client-secret-env/--client-secret-file or --federated");
155
- if (application && allowDeviceCode)
156
- invalid("Application profiles never use device code; it is a delegated login method");
157
- const credentialRef = certificate === undefined
158
- ? secretEnv === undefined && secretFile === undefined
159
- ? { provider: "federated", key: randomUUID(), ...(tokenFileEnv === undefined ? {} : { tokenFileEnv }) }
160
- : { provider: "client-secret", key: randomUUID(), ...(secretEnv === undefined ? { secretFile: secretFile } : { secretEnv }) }
161
- : { provider: "certificate", key: randomUUID(), thumbprint: certificate, ...(keyFile === undefined && keyEnv === undefined ? {} : keyFile === undefined ? { keyEnv: keyEnv } : { keyFile }) };
162
- const profile = application
163
- ? validateApplicationProfile({ mode: "application", tenantId, clientId, cloud, enabledPacks: ["entra"], preview: false, sensitiveAreas: [],
164
- credentialRef, allowDeviceCode: false })
165
- : validateDelegatedProfile({ mode: "delegated", tenantId, clientId, cloud, enabledPacks: ["entra"], preview: false, sensitiveAreas: [], credentialRef: { provider: "os-or-session", key: randomUUID() }, allowDeviceCode });
166
223
  const config = this.load();
167
224
  if (Object.hasOwn(config.profiles, name))
168
225
  invalid("Profile already exists; creation never replaces identity");
169
226
  config.profiles[name] = profile;
170
227
  config.defaultProfile ??= name;
171
- mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });
172
- const temporary = `${this.path}.${randomUUID()}.tmp`;
173
- try {
174
- writeFileSync(temporary, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600, flag: "wx" });
175
- renameSync(temporary, this.path);
176
- }
177
- catch {
178
- throw new AxiError("Unable to save profile configuration", "CONFIG_ERROR", ["Check configuration directory permissions"]);
179
- }
180
- finally {
181
- rmSync(temporary, { force: true });
182
- }
228
+ this.files.write(this.path, `${JSON.stringify(config, null, 2)}\n`);
183
229
  return { name, mode: profile.mode, tenant: profile.tenantId, cloud: profile.cloud };
184
230
  }
185
231
  }
package/dist/redact.js ADDED
@@ -0,0 +1,26 @@
1
+ const secrets = new Set();
2
+ export function registerSecret(value) {
3
+ if (!value)
4
+ return value;
5
+ for (const form of [value, JSON.stringify(value).slice(1, -1), encodeURIComponent(value)])
6
+ secrets.add(form);
7
+ return value;
8
+ }
9
+ export function redactSecrets(value) {
10
+ for (const secret of [...secrets].sort((a, b) => b.length - a.length))
11
+ value = value.split(secret).join("***redacted***");
12
+ return value;
13
+ }
14
+ // MSAL caches contain opaque refresh, access and ID tokens under secret fields.
15
+ export function registerCache(value) {
16
+ registerSecret(value);
17
+ const visit = (entry, key = "") => {
18
+ if (typeof entry === "string" && /^(secret|accessToken|refreshToken|idToken)$/i.test(key))
19
+ registerSecret(entry);
20
+ else if (entry && typeof entry === "object")
21
+ for (const [name, child] of Object.entries(entry))
22
+ visit(child, name);
23
+ };
24
+ visit(JSON.parse(value));
25
+ return value;
26
+ }
@@ -0,0 +1,37 @@
1
+ import { AxiError } from "axi-sdk-js";
2
+ import { PrivateFiles } from "./private-file.js";
3
+ import { registerSecret } from "./redact.js";
4
+ export function validateSecretRef(value, field, stored = true, files = new PrivateFiles()) {
5
+ if (value === undefined)
6
+ return Object.freeze({ env: `MG_AXI_${field.replace(/[A-Z]/g, letter => `_${letter}`).toUpperCase()}` });
7
+ if (value && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === 1) {
8
+ const ref = value;
9
+ if (typeof ref.env === "string" && /^[A-Za-z_][A-Za-z0-9_]*$/.test(ref.env))
10
+ return Object.freeze({ env: ref.env });
11
+ if (typeof ref.file === "string" && files.absolute(ref.file))
12
+ return Object.freeze({ file: ref.file });
13
+ if (stored && ref.stored === true)
14
+ return Object.freeze({ stored: true });
15
+ }
16
+ throw new AxiError(`Invalid ${field}; use a SecretRef, never an inline secret`, "CONFIG_INVALID", ["Use exactly one validated env name, absolute local file path, or stored: true for a delegated login"]);
17
+ }
18
+ export function secretSource(ref) { return "env" in ref ? `env:${ref.env}` : "file" in ref ? `file:${ref.file}` : "stored"; }
19
+ export function resolveSecret(ref, files = new PrivateFiles(), verifyAcl = false) {
20
+ let value;
21
+ if ("env" in ref)
22
+ value = process.env[ref.env];
23
+ else if ("file" in ref) {
24
+ try {
25
+ value = files.read(ref.file, true, verifyAcl).trimEnd();
26
+ }
27
+ catch (error) {
28
+ if (error instanceof AxiError)
29
+ throw error;
30
+ if (error?.code !== "ENOENT")
31
+ throw error;
32
+ }
33
+ }
34
+ if (!value?.trim())
35
+ throw new AxiError(`Credential unavailable from ${secretSource(ref)}`, "AUTH_REQUIRED", ["Supply the named credential reference; no interactive fallback was attempted"]);
36
+ return registerSecret(value);
37
+ }
package/dist/setup.js CHANGED
@@ -105,9 +105,9 @@ export function setupView(store) {
105
105
  "node dist/bin/mg-axi.js --version",
106
106
  ],
107
107
  guidance: [
108
- `Hand-edit ${store.path} or set MG_AXI_CONFIG to a separate configuration file; a profile stores only a credential reference - an OS keychain entry, an environment variable name, or a file path - and never the secret value, never in argv or config`,
108
+ `Hand-edit ${store.path} or set MG_AXI_CONFIG to a separate configuration file; a profile stores only a SecretRef - an environment variable name, an absolute file path, or stored: true for delegated login - and never the secret value, never in argv or config`,
109
109
  "node dist/bin/mg-axi.js profile create --name soc --tenant <tenant-id> --client <client-id> --cloud commercial",
110
- "node dist/bin/mg-axi.js login --profile soc --scopes https://graph.microsoft.com/User.Read.All",
110
+ "node dist/bin/mg-axi.js auth login --profile soc --scopes https://graph.microsoft.com/User.Read.All",
111
111
  "node dist/bin/mg-axi.js doctor",
112
112
  ],
113
113
  capabilities: {
package/docs/coverage.md CHANGED
@@ -14,7 +14,7 @@ scheduled work as complete.
14
14
 
15
15
  - implemented read leaves: 263 (261 named Entra reads plus reviewed raw api get and doctor health check)
16
16
  - implemented write leaves: 5 (named gated mutations below)
17
- - local leaves: 7 (home, profile, login and setup views)
17
+ - local leaves: 11 (home, profile, config, auth, login alias and setup views)
18
18
  - inventory named-command: 289
19
19
  - inventory reviewed-raw-read: 5
20
20
  - inventory scheduled: 3717
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@knowttl/mg-axi",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Agent-facing Microsoft Graph CLI, read-only by default",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,7 +20,8 @@
20
20
  "inventory/operations.json",
21
21
  "skills/mg-axi",
22
22
  "docs/coverage.md",
23
- "README.md"
23
+ "README.md",
24
+ "axi-credential.schema.json"
24
25
  ],
25
26
  "engines": {
26
27
  "node": ">=22.12.0"
@@ -28,7 +29,7 @@
28
29
  "packageManager": "pnpm@10.34.6",
29
30
  "scripts": {
30
31
  "build": "tsc -p tsconfig.json",
31
- "test": "node --test --test-concurrency=4 test/*.test.mjs",
32
+ "test": "node tools/isolated-home.mjs node --test --test-concurrency=4 test/*.test.mjs",
32
33
  "lint": "tsc --noEmit -p tsconfig.json",
33
34
  "docs:generate": "node tools/generate-docs.mjs",
34
35
  "docs:check": "node tools/generate-docs.mjs --check"
@@ -42,8 +43,5 @@
42
43
  "devDependencies": {
43
44
  "@types/node": "^22.20.5",
44
45
  "typescript": "^7.0.2"
45
- },
46
- "optionalDependencies": {
47
- "keytar": "^7.9.0"
48
46
  }
49
47
  }
@@ -17,13 +17,13 @@ See [README.md](../../README.md) for write usage, execution gates, identity pinn
17
17
  Run commands non-interactively as `npx -y @knowttl/mg-axi ...`: no global install needed and no interactive prompts.
18
18
  Version pinning is the installer's choice: use `npx -y @knowttl/mg-axi@<version> ...` to select a specific release.
19
19
  Run `npx -y @knowttl/mg-axi doctor` first.
20
- See [README.md](../../README.md#release) for doctor profile selection, checks and failure behavior.
20
+ See [README.md](../../README.md#usage) for doctor profile selection, checks and failure behavior.
21
21
 
22
22
  ## Orientation
23
23
 
24
24
  The exact current leaf registry is `src/catalogue.ts`.
25
25
  Its capability label is `native` (implemented by an mg-axi handler).
26
- 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.
26
+ 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.
27
27
  Doctor acquires credentials silently and contacts Graph; only its help view stays offline.
28
28
  The list below records current executable leaves; it makes no coverage claim for other Graph operations.
29
29
  See `docs/coverage.md` for the per-operation disposition records.
@@ -36,7 +36,7 @@ See `docs/coverage.md` for the per-operation disposition records.
36
36
  | `mg-axi profile create` | native | local |
37
37
  | `mg-axi profile list` | native | local |
38
38
  | `mg-axi profile show` | native | local |
39
- | `mg-axi login` | native | local |
39
+ | `mg-axi auth login` | native | local |
40
40
  | `mg-axi setup` | native | local |
41
41
  | `mg-axi setup hooks` | native | local |
42
42
  | `mg-axi doctor` | native | read |
@@ -307,6 +307,10 @@ See `docs/coverage.md` for the per-operation disposition records.
307
307
  | `mg-axi entra lifecycle run task-processing-result show` | native | read |
308
308
  | `mg-axi entra lifecycle run task-processing-result count` | native | read |
309
309
  | `mg-axi api get` | native | read |
310
+ | `mg-axi login` | native | local |
311
+ | `mg-axi config path` | native | local |
312
+ | `mg-axi auth status` | native | local |
313
+ | `mg-axi auth logout` | native | local |
310
314
  <!-- command-registry:end -->
311
315
 
312
316
  Run `npx -y @knowttl/mg-axi <leaf-path> --help` for that leaf's accepted flags and reference.
@@ -318,7 +322,7 @@ The no-args home view prints these hints alongside live profile status:
318
322
 
319
323
  ```sh
320
324
  npx -y @knowttl/mg-axi profile list
321
- npx -y @knowttl/mg-axi login --help
325
+ npx -y @knowttl/mg-axi auth login --help
322
326
  npx -y @knowttl/mg-axi setup
323
327
  npx -y @knowttl/mg-axi doctor
324
328
  npx -y @knowttl/mg-axi entra user list --help
@@ -333,18 +337,18 @@ npx -y @knowttl/mg-axi api get --help
333
337
 
334
338
  ## Setup (explicit only)
335
339
 
336
- Follow [release guidance](../../README.md#release) for npm installation and explicit skill installation; [README.md](../../README.md#mg-axi) also documents checkout builds.
340
+ Follow [installation guidance](../../README.md#quick-start) for npm installation and explicit skill installation; [README.md](../../README.md#development--testing) also documents checkout builds.
337
341
  Create profiles explicitly and keep secrets out of argv and config files:
338
342
 
339
343
  ```sh
340
344
  npx -y @knowttl/mg-axi setup # build steps, config path and capabilities; writes nothing
341
345
  npx -y @knowttl/mg-axi profile create --name soc --tenant <tenant-id> --client <client-id> --cloud commercial
342
- npx -y @knowttl/mg-axi login --profile soc --scopes https://graph.microsoft.com/User.Read.All
346
+ npx -y @knowttl/mg-axi auth login --profile soc --scopes https://graph.microsoft.com/User.Read.All
343
347
  npx -y @knowttl/mg-axi doctor # one bounded read per selected profile
344
348
  npx -y @knowttl/mg-axi entra user list --profile soc --limit 10
345
349
  ```
346
350
 
347
- Configuration defaults to `~/.mg-axi/config.json`; `MG_AXI_CONFIG` selects a separate file.
351
+ 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`.
348
352
  `mg-axi setup` shows the selected path and writes nothing.
349
353
  Application profiles authenticate headlessly through credential references: the profile names an env var or file and the environment supplies the value at acquisition time.
350
354
  Container examples for client secrets, certificate keys and workload federation live in [README.md](../../README.md#mg-axi).
@@ -366,14 +370,13 @@ No other ordinary command installs hooks, plugins or configuration.
366
370
  ## Selecting a profile
367
371
 
368
372
  Read leaves accept `--profile <name>`.
369
- Selection uses `--profile` or the configured default.
370
- Without either, read leaves report `AUTH_REQUIRED`, even if only one profile exists.
371
- Doctor selection is documented in [README.md](../../README.md#release).
373
+ Profile selection order and env-profile rules live in [README Configuration](../../README.md#configuration); an explicitly selected file profile ignores env fields.
374
+ Doctor selection is documented in [README.md](../../README.md#usage).
372
375
  Without profiles, home, setup and profile list show unconfigured state; Graph read leaves report `AUTH_REQUIRED`.
373
376
 
374
377
  ## Safety
375
378
 
376
- 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.
379
+ 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.
377
380
  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.
378
381
  Output is TOON on stdout; diagnostics use stderr.
379
382
  Text truncates at 500 characters (`--full` restores text, never redaction); row caps resume through opaque cursors.