@knowttl/mg-axi 0.1.3 → 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/README.md +304 -1250
- package/axi-credential.schema.json +65 -0
- package/dist/app-auth.js +13 -4
- package/dist/auth-status.js +20 -0
- package/dist/auth.js +24 -10
- package/dist/catalogue.js +17 -6
- package/dist/cli.js +60 -27
- package/dist/credentials.js +78 -0
- package/dist/docs.js +14 -12
- package/dist/doctor.js +10 -8
- package/dist/graph-session.js +15 -11
- package/dist/hook.js +9 -32
- package/dist/msal-app-provider.js +32 -23
- package/dist/msal-provider.js +67 -54
- package/dist/mutations.js +42 -53
- package/dist/paths.js +17 -0
- package/dist/private-file.js +227 -0
- package/dist/profiles.js +181 -89
- package/dist/redact.js +26 -0
- package/dist/secrets.js +37 -0
- package/dist/setup.js +2 -2
- package/docs/coverage.md +1 -1
- package/package.json +4 -6
- package/skills/mg-axi/SKILL.md +18 -12
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
|
|
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
|
-
|
|
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", "
|
|
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
|
-
|
|
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.
|
package/dist/graph-session.js
CHANGED
|
@@ -2,7 +2,9 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import { setTimeout as delay } from "node:timers/promises";
|
|
4
4
|
import { AxiError } from "axi-sdk-js";
|
|
5
|
-
import {
|
|
5
|
+
import { redactSecrets } from "./redact.js";
|
|
6
|
+
import { PrivateFiles } from "./private-file.js";
|
|
7
|
+
import { validateProfile, profileName } from "./profiles.js";
|
|
6
8
|
// CORE-01: the single path that owns Graph URL construction, operation
|
|
7
9
|
// authorization, credential attachment and response validation. Command
|
|
8
10
|
// handlers call execute() with a catalogued inventory operation and bound
|
|
@@ -138,7 +140,7 @@ export function redactGraphValue(value) {
|
|
|
138
140
|
}
|
|
139
141
|
function redact(value) {
|
|
140
142
|
if (typeof value === "string")
|
|
141
|
-
return SECRET_VALUE.some(pattern => pattern.test(value)) ? REDACTED : value;
|
|
143
|
+
return SECRET_VALUE.some(pattern => pattern.test(value)) ? REDACTED : redactSecrets(value);
|
|
142
144
|
if (Array.isArray(value))
|
|
143
145
|
return value.map(redact);
|
|
144
146
|
if (value === null || typeof value !== "object")
|
|
@@ -617,8 +619,10 @@ function valuesOf(operation, body) {
|
|
|
617
619
|
}
|
|
618
620
|
export class GraphSession {
|
|
619
621
|
deps;
|
|
620
|
-
|
|
622
|
+
files;
|
|
623
|
+
constructor(deps, files = new PrivateFiles()) {
|
|
621
624
|
this.deps = deps;
|
|
625
|
+
this.files = files;
|
|
622
626
|
}
|
|
623
627
|
cursorQuery(operation, cursor) {
|
|
624
628
|
return decodeCursor(operation, cursor).query;
|
|
@@ -633,7 +637,7 @@ export class GraphSession {
|
|
|
633
637
|
"Use a route from inventory/operations.json; the session never builds uncatalogued URLs",
|
|
634
638
|
]);
|
|
635
639
|
}
|
|
636
|
-
const profile = validateProfile(args.profile);
|
|
640
|
+
const profile = validateProfile(args.profile, this.files);
|
|
637
641
|
this.authorizePolicy(profile, operation);
|
|
638
642
|
const query = args.query ?? {};
|
|
639
643
|
checkQueryContext(query, args.consistencyLevel);
|
|
@@ -645,8 +649,8 @@ export class GraphSession {
|
|
|
645
649
|
let token;
|
|
646
650
|
try {
|
|
647
651
|
token = await beforeDeadline(async () => profile.mode === "delegated"
|
|
648
|
-
? (await this.deps.delegated.credential(
|
|
649
|
-
: (await this.deps.application.credential(
|
|
652
|
+
? (await this.deps.delegated.credential(profile, this.readScopes(args.scopes))).token
|
|
653
|
+
: (await this.deps.application.credential(profile, this.applicationScopes(args.scopes))).token, clock, deadline, signal);
|
|
650
654
|
}
|
|
651
655
|
catch (error) {
|
|
652
656
|
signal?.throwIfAborted();
|
|
@@ -671,7 +675,7 @@ export class GraphSession {
|
|
|
671
675
|
"Use a route from inventory/operations.json; the session never builds uncatalogued URLs",
|
|
672
676
|
]);
|
|
673
677
|
}
|
|
674
|
-
const profile = validateProfile(args.profile);
|
|
678
|
+
const profile = validateProfile(args.profile, this.files);
|
|
675
679
|
this.authorizePolicy(profile, operation);
|
|
676
680
|
checkLimit(args.limit);
|
|
677
681
|
const budget = checkBudget(args.budget);
|
|
@@ -690,8 +694,8 @@ export class GraphSession {
|
|
|
690
694
|
const signal = args.signal;
|
|
691
695
|
const path = buildPath(operation.path, params);
|
|
692
696
|
const context = createHash("sha256").update(JSON.stringify([
|
|
693
|
-
path, profile.mode, profile.tenantId.toLowerCase(), profile.clientId.toLowerCase(), profile.cloud,
|
|
694
|
-
profile.
|
|
697
|
+
path, profileName(profile), profile.mode, profile.tenantId.toLowerCase(), profile.clientId.toLowerCase(), profile.cloud,
|
|
698
|
+
profile.mode === "delegated" ? profile.token : profile.auth === "certificate" ? [profile.privateKey, profile.thumbprintSha256] : profile.auth === "client-secret" ? profile.clientSecret : profile.token, [...new Set(args.scopes ?? [])].sort(),
|
|
695
699
|
])).digest("hex");
|
|
696
700
|
if (resumed && resumed.context !== context)
|
|
697
701
|
throw new AxiError("Collection cursor resource or authentication context does not match", "VALIDATION_ERROR", ["Resume under the original resource bindings, profile identity, credential reference and scopes"]);
|
|
@@ -733,8 +737,8 @@ export class GraphSession {
|
|
|
733
737
|
let token;
|
|
734
738
|
try {
|
|
735
739
|
const credential = await beforeDeadline(async () => profile.mode === "delegated"
|
|
736
|
-
? this.deps.delegated.credential(
|
|
737
|
-
: this.deps.application.credential(
|
|
740
|
+
? this.deps.delegated.credential(profile, this.readScopes(args.scopes))
|
|
741
|
+
: this.deps.application.credential(profile, this.applicationScopes(args.scopes)), clock, deadline, signal);
|
|
738
742
|
const acquiredIdentity = createHash("sha256").update(JSON.stringify([
|
|
739
743
|
profile.mode, credential.tenantId.toLowerCase(), credential.clientId.toLowerCase(),
|
|
740
744
|
profile.mode === "delegated" && "accountId" in credential ? credential.accountId : null,
|
package/dist/hook.js
CHANGED
|
@@ -1,37 +1,14 @@
|
|
|
1
1
|
import { encode } from "@toon-format/toon";
|
|
2
2
|
import { Profiles } from "./profiles.js";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
// exit code 0, so a missing or broken config never breaks agent startup.
|
|
9
|
-
//
|
|
10
|
-
// Auth-cache presence reads the OS credential store directly: delegated
|
|
11
|
-
// profiles keep their MSAL session blob under the "mg-axi" service with the
|
|
12
|
-
// session key below, so presence means a previous explicit login happened on
|
|
13
|
-
// this machine. Only presence is reported; tokens and key material never
|
|
14
|
-
// reach the output. Application profiles acquire tokens silently per run, so
|
|
15
|
-
// they report the client-credentials mechanism instead of cache state.
|
|
16
|
-
// Session cache key mirrors MsalProvider.application() in
|
|
17
|
-
// src/msal-provider.ts; change both together.
|
|
18
|
-
function delegatedSessionKey(profile) {
|
|
19
|
-
return JSON.stringify([profile.credentialRef.key, profile.tenantId, profile.clientId, profile.cloud]);
|
|
20
|
-
}
|
|
21
|
-
async function probeAuthPresence(profile) {
|
|
3
|
+
import { Credentials } from "./credentials.js";
|
|
4
|
+
import { PrivateFiles } from "./private-file.js";
|
|
5
|
+
import { resolveSecret } from "./secrets.js";
|
|
6
|
+
import { redactSecrets } from "./redact.js";
|
|
7
|
+
async function probeAuthPresence(profile, files = new PrivateFiles()) {
|
|
22
8
|
if (profile.mode !== "delegated")
|
|
23
9
|
return "client-credentials";
|
|
24
|
-
let store;
|
|
25
|
-
try {
|
|
26
|
-
store = await import("keytar").then(module => module.default).catch(() => undefined);
|
|
27
|
-
}
|
|
28
|
-
catch {
|
|
29
|
-
return "unknown";
|
|
30
|
-
}
|
|
31
|
-
if (!store)
|
|
32
|
-
return "unknown";
|
|
33
10
|
try {
|
|
34
|
-
return
|
|
11
|
+
return ("stored" in profile.token ? new Credentials(undefined, files).read(profile) : resolveSecret(profile.token, files)) ? "cached" : "sign-in required";
|
|
35
12
|
}
|
|
36
13
|
catch {
|
|
37
14
|
return "unknown";
|
|
@@ -44,17 +21,17 @@ const CONFIGURED_HINT = "Run npx -y @knowttl/mg-axi doctor --profile <name> to c
|
|
|
44
21
|
const SETUP_HINT = "Run npx -y @knowttl/mg-axi setup to find the config path and example";
|
|
45
22
|
export async function hookSummary(options) {
|
|
46
23
|
try {
|
|
47
|
-
return await renderHookSummary(options);
|
|
24
|
+
return redactSecrets(await renderHookSummary(options));
|
|
48
25
|
}
|
|
49
26
|
catch {
|
|
50
27
|
return "mg: status unavailable\n";
|
|
51
28
|
}
|
|
52
29
|
}
|
|
53
|
-
async function renderHookSummary({ version, env = process.env, configPath, authPresence = probeAuthPresence }) {
|
|
30
|
+
async function renderHookSummary({ version, env = process.env, configPath, files = new PrivateFiles(), authPresence = profile => probeAuthPresence(profile, files) }) {
|
|
54
31
|
let store;
|
|
55
32
|
let names;
|
|
56
33
|
try {
|
|
57
|
-
store = new Profiles(configPath
|
|
34
|
+
store = new Profiles(configPath !== undefined && configPath.trim() !== "" ? configPath.trim() : env.MG_AXI_CONFIG !== undefined && env.MG_AXI_CONFIG.trim() !== "" ? env.MG_AXI_CONFIG.trim() : undefined, files);
|
|
58
35
|
names = store.list().map(item => item.name);
|
|
59
36
|
}
|
|
60
37
|
catch {
|
|
@@ -1,15 +1,24 @@
|
|
|
1
|
-
import { readFileSync } from "node:fs";
|
|
2
1
|
import { ConfidentialClientApplication } from "@azure/msal-node";
|
|
2
|
+
import { AxiError } from "axi-sdk-js";
|
|
3
3
|
import { graphAudience } from "./app-auth.js";
|
|
4
|
-
|
|
4
|
+
import { PrivateFiles } from "./private-file.js";
|
|
5
|
+
import { resolveSecret } from "./secrets.js";
|
|
6
|
+
import { registerSecret } from "./redact.js";
|
|
7
|
+
// Application acquisition has no interactive or persistent credential fallback.
|
|
5
8
|
export class MsalApplicationProvider {
|
|
9
|
+
files;
|
|
6
10
|
storage = "session-only";
|
|
11
|
+
constructor(files = new PrivateFiles()) {
|
|
12
|
+
this.files = files;
|
|
13
|
+
}
|
|
7
14
|
async acquire(profile, scopes) {
|
|
8
15
|
const configuration = {
|
|
9
16
|
auth: { clientId: profile.clientId, authority: `https://login.microsoftonline.com/${profile.tenantId}`,
|
|
10
|
-
...(profile.
|
|
11
|
-
? {
|
|
12
|
-
:
|
|
17
|
+
...(profile.auth === "client-secret"
|
|
18
|
+
? { clientSecret: resolveSecret(profile.clientSecret, this.files) }
|
|
19
|
+
: profile.auth === "certificate"
|
|
20
|
+
? { clientCertificate: { thumbprintSha256: profile.thumbprintSha256, privateKey: this.privateKey(profile) } }
|
|
21
|
+
: { clientAssertion: () => Promise.resolve(this.assertion(profile)) }) },
|
|
13
22
|
system: { loggerOptions: { loggerCallback: () => { }, piiLoggingEnabled: false } },
|
|
14
23
|
};
|
|
15
24
|
const result = await new ConfidentialClientApplication(configuration)
|
|
@@ -18,25 +27,25 @@ export class MsalApplicationProvider {
|
|
|
18
27
|
throw new Error("Empty application token response");
|
|
19
28
|
if (result.authority.toLowerCase() !== `${configuration.auth.authority.toLowerCase()}/`)
|
|
20
29
|
throw new Error("Configured application authority mismatch");
|
|
21
|
-
return { token: result.accessToken, expiresAt: result.expiresOn?.getTime() ?? NaN, tenantId: profile.tenantId, clientId: profile.clientId };
|
|
30
|
+
return { token: registerSecret(result.accessToken), expiresAt: result.expiresOn?.getTime() ?? NaN, tenantId: profile.tenantId, clientId: profile.clientId };
|
|
22
31
|
}
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
32
|
+
assertion(profile) {
|
|
33
|
+
if ("env" in profile.token && profile.token.env === "MG_AXI_TOKEN" && !process.env.MG_AXI_TOKEN?.trim()) {
|
|
34
|
+
const legacy = process.env.AZURE_FEDERATED_TOKEN_FILE?.trim();
|
|
35
|
+
if (legacy) {
|
|
36
|
+
process.stderr.write("AZURE_FEDERATED_TOKEN_FILE is deprecated; use MG_AXI_TOKEN instead\n");
|
|
37
|
+
if (!this.files.absolute(legacy))
|
|
38
|
+
throw new AxiError("AZURE_FEDERATED_TOKEN_FILE must name an absolute file path", "CONFIG_INVALID", ["Set MG_AXI_TOKEN to the workload assertion"]);
|
|
39
|
+
return resolveSecret({ file: legacy }, this.files);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return resolveSecret(profile.token, this.files);
|
|
30
43
|
}
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
const
|
|
34
|
-
if (
|
|
35
|
-
throw new
|
|
36
|
-
|
|
37
|
-
const assertion = readFileSync(file, "utf8").trim();
|
|
38
|
-
if (!assertion)
|
|
39
|
-
throw new Error("Missing federated token source");
|
|
40
|
-
return assertion;
|
|
44
|
+
privateKey(profile) {
|
|
45
|
+
const material = resolveSecret(profile.privateKey, this.files);
|
|
46
|
+
const normalized = material.includes("\n") ? material : material.replace(/\\n/g, "\n");
|
|
47
|
+
if (/ENCRYPTED PRIVATE KEY/.test(normalized) || /Proc-Type:\s*4,ENCRYPTED/.test(normalized))
|
|
48
|
+
throw new AxiError("Referenced privateKey is passphrase-protected; passphrase-protected keys are unsupported", "AUTH_REQUIRED", ["Provision an unencrypted PEM through the referenced environment variable or file"]);
|
|
49
|
+
return registerSecret(normalized);
|
|
41
50
|
}
|
|
42
51
|
}
|
package/dist/msal-provider.js
CHANGED
|
@@ -1,82 +1,95 @@
|
|
|
1
1
|
import { PublicClientApplication } from "@azure/msal-node";
|
|
2
2
|
import open from "open";
|
|
3
|
-
|
|
3
|
+
import { AxiError } from "axi-sdk-js";
|
|
4
|
+
import { profileName } from "./profiles.js";
|
|
5
|
+
import { Credentials } from "./credentials.js";
|
|
6
|
+
import { resolveSecret } from "./secrets.js";
|
|
7
|
+
import { redactSecrets, registerCache, registerSecret } from "./redact.js";
|
|
4
8
|
export class MsalProvider {
|
|
5
|
-
|
|
9
|
+
store;
|
|
10
|
+
storage = "protected-file";
|
|
6
11
|
sessions = new Map();
|
|
7
|
-
|
|
8
|
-
|
|
12
|
+
constructor(store = new Credentials()) {
|
|
13
|
+
this.store = store;
|
|
14
|
+
}
|
|
15
|
+
key(profile) { return JSON.stringify([profileName(profile), profile.tenantId, profile.clientId, profile.cloud, profile.token]); }
|
|
16
|
+
application(profile, login = false) {
|
|
17
|
+
const key = this.key(profile);
|
|
18
|
+
this.storage = "stored" in profile.token ? "protected-file" : "session-only";
|
|
9
19
|
const existing = this.sessions.get(key);
|
|
10
|
-
if (existing && !
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
const
|
|
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 = {
|
|
20
|
+
if (existing && !login)
|
|
21
|
+
return existing;
|
|
22
|
+
let loaded = false;
|
|
23
|
+
let snapshot;
|
|
24
|
+
const app = new PublicClientApplication({
|
|
25
25
|
auth: { clientId: profile.clientId, authority: `https://login.microsoftonline.com/${profile.tenantId}` },
|
|
26
26
|
system: { loggerOptions: { loggerCallback: () => { }, piiLoggingEnabled: false } },
|
|
27
|
-
|
|
28
|
-
const app = new PublicClientApplication({
|
|
29
|
-
...configuration,
|
|
30
|
-
cache: store ? { cachePlugin: {
|
|
27
|
+
cache: { cachePlugin: {
|
|
31
28
|
beforeCacheAccess: async (context) => {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
29
|
+
if (!loaded) {
|
|
30
|
+
snapshot = "stored" in profile.token ? this.store.read(profile) : resolveSecret(profile.token, this.store.files);
|
|
31
|
+
loaded = true;
|
|
32
|
+
}
|
|
33
|
+
// MSAL overwrites its cache from the plugin snapshot before a silent lookup.
|
|
34
|
+
if (snapshot)
|
|
35
|
+
context.tokenCache.deserialize(registerCache(snapshot));
|
|
35
36
|
},
|
|
36
37
|
afterCacheAccess: async (context) => {
|
|
37
38
|
if (!context.cacheHasChanged)
|
|
38
39
|
return;
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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);
|
|
40
|
+
snapshot = registerCache(context.tokenCache.serialize());
|
|
41
|
+
// Hosted commands keep refreshed tokens only in memory. Explicit login owns persistence.
|
|
42
|
+
if (login)
|
|
43
|
+
this.store.write(profile, snapshot);
|
|
51
44
|
},
|
|
52
|
-
} }
|
|
45
|
+
} },
|
|
53
46
|
});
|
|
54
|
-
this.sessions.set(key,
|
|
47
|
+
this.sessions.set(key, app);
|
|
55
48
|
return app;
|
|
56
49
|
}
|
|
57
50
|
async login(profile, method, scopes) {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
51
|
+
if (!("stored" in profile.token))
|
|
52
|
+
throw new AxiError("auth login requires token: {stored: true}", "CONFIG_INVALID");
|
|
53
|
+
this.sessions.delete(this.key(profile));
|
|
54
|
+
this.store.remove(profile);
|
|
55
|
+
const app = this.application(profile, true);
|
|
56
|
+
try {
|
|
57
|
+
const result = method === "browser"
|
|
58
|
+
? await app.acquireTokenInteractive({ scopes, openBrowser: async (url) => { await open(url); }, prompt: "select_account", errorTemplate: "Sign-in failed. Return to mg-axi for recovery guidance." })
|
|
59
|
+
: await app.acquireTokenByDeviceCode({ scopes, deviceCodeCallback: response => {
|
|
60
|
+
if (typeof response.userCode !== "string" || !response.userCode.trim() || typeof response.deviceCode !== "string" || !response.deviceCode.trim() || !Number.isFinite(response.expiresIn) || response.expiresIn <= 0)
|
|
61
|
+
throw new Error("Invalid device-code challenge");
|
|
62
|
+
process.stderr.write(redactSecrets(`Open https://microsoft.com/devicelogin and enter ${response.userCode}\n`));
|
|
63
|
+
} });
|
|
64
|
+
const credential = this.result(profile, result);
|
|
65
|
+
// Subsequent silent acquisitions must not retain a persistence-enabled plugin.
|
|
66
|
+
this.sessions.delete(this.key(profile));
|
|
67
|
+
return credential;
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
this.sessions.delete(this.key(profile));
|
|
71
|
+
this.store.remove(profile);
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
69
74
|
}
|
|
70
75
|
async silent(profile, scopes) {
|
|
71
|
-
const app =
|
|
76
|
+
const app = this.application(profile);
|
|
72
77
|
const accounts = (await app.getTokenCache().getAllAccounts()).filter(account => account.tenantId.toLowerCase() === profile.tenantId.toLowerCase());
|
|
73
78
|
if (accounts.length !== 1)
|
|
74
79
|
throw new Error("Explicit login required");
|
|
75
|
-
|
|
80
|
+
const credential = this.result(profile, await app.acquireTokenSilent({ scopes, account: accounts[0] }));
|
|
81
|
+
if (credential.accountId !== accounts[0].homeAccountId)
|
|
82
|
+
throw new Error("Configured account mismatch");
|
|
83
|
+
return credential;
|
|
84
|
+
}
|
|
85
|
+
async logout(profile) {
|
|
86
|
+
this.sessions.delete(this.key(profile));
|
|
87
|
+
if ("stored" in profile.token)
|
|
88
|
+
this.store.remove(profile);
|
|
76
89
|
}
|
|
77
90
|
result(profile, result) {
|
|
78
91
|
if (!result?.account || result.tenantId.toLowerCase() !== profile.tenantId.toLowerCase())
|
|
79
92
|
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 };
|
|
93
|
+
return { token: registerSecret(result.accessToken), expiresAt: result.expiresOn?.getTime() ?? NaN, tenantId: result.tenantId, clientId: profile.clientId, accountId: result.account.homeAccountId };
|
|
81
94
|
}
|
|
82
95
|
}
|
package/dist/mutations.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import { closeSync, existsSync, fsyncSync,
|
|
1
|
+
import { closeSync, existsSync, fsyncSync, openSync, unlinkSync } from "node:fs";
|
|
2
2
|
import { createHash, randomUUID } from "node:crypto";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { dirname, join } from "node:path";
|
|
5
5
|
import { AxiError } from "axi-sdk-js";
|
|
6
6
|
import { encodeGraphPathSegment, GRAPH_HOST, redactGraphValue } from "./graph-session.js";
|
|
7
|
-
import { validateApplicationProfile, validateDelegatedProfile } from "./profiles.js";
|
|
7
|
+
import { booleanEnv, movedError, validateApplicationProfile, validateDelegatedProfile } from "./profiles.js";
|
|
8
|
+
import { statePath } from "./paths.js";
|
|
9
|
+
import { PrivateFiles } from "./private-file.js";
|
|
8
10
|
// Shared mutation coordinator for reviewed named mutation families.
|
|
9
11
|
// The gate order follows az-axi's write gates as the reference: read-only default,
|
|
10
12
|
// allowWrites plus a scope allowlist, preview, --execute, --confirm,
|
|
@@ -15,15 +17,19 @@ import { validateApplicationProfile, validateDelegatedProfile } from "./profiles
|
|
|
15
17
|
export const READ_ONLY_ENV = "MG_AXI_READ_ONLY";
|
|
16
18
|
export const WRITE_LOG_ENV = "MG_AXI_WRITE_LOG";
|
|
17
19
|
export function readOnlyForced(env = process.env) {
|
|
18
|
-
return
|
|
20
|
+
return booleanEnv(READ_ONLY_ENV, env);
|
|
19
21
|
}
|
|
20
22
|
// Mutation intents and outcomes are journaled here: MG_AXI_WRITE_LOG when
|
|
21
|
-
// set, otherwise
|
|
23
|
+
// set, otherwise the machine-local state directory. A blank override falls back.
|
|
22
24
|
export function resolveWriteLogPath(env = process.env) {
|
|
23
25
|
const override = env[WRITE_LOG_ENV]?.trim();
|
|
24
26
|
if (override)
|
|
25
27
|
return override;
|
|
26
|
-
|
|
28
|
+
const target = statePath("writes.log", env);
|
|
29
|
+
const legacy = join(homedir(), ".mg-axi", "writes.log");
|
|
30
|
+
if (!existsSync(target) && existsSync(legacy))
|
|
31
|
+
movedError("Write log", legacy, target, "The moved journal keeps its intent and outcome history");
|
|
32
|
+
return target;
|
|
27
33
|
}
|
|
28
34
|
export const mutationFetchTransport = async (request) => {
|
|
29
35
|
const response = await fetch(request.url, {
|
|
@@ -155,8 +161,8 @@ function decodeMutationBody(response) {
|
|
|
155
161
|
// send needs a coordinator authorization minted after the write gates pass.
|
|
156
162
|
// The bound profile copy is private; no profile snapshot is exposed.
|
|
157
163
|
export function createMutationSender(args) {
|
|
158
|
-
const { delegated, application, transport } = args;
|
|
159
|
-
const bound = args.profile.mode === "application" ? validateApplicationProfile(args.profile) : validateDelegatedProfile(args.profile);
|
|
164
|
+
const { delegated, application, transport, files = new PrivateFiles() } = args;
|
|
165
|
+
const bound = args.profile.mode === "application" ? validateApplicationProfile(args.profile, files) : validateDelegatedProfile(args.profile, files);
|
|
160
166
|
async function credential(scopes, signal) {
|
|
161
167
|
signal?.throwIfAborted();
|
|
162
168
|
if (bound.mode === "delegated") {
|
|
@@ -165,14 +171,14 @@ export function createMutationSender(args) {
|
|
|
165
171
|
"Pass the operation's documented delegated permission using full https://graph.microsoft.com/ scope names",
|
|
166
172
|
]);
|
|
167
173
|
}
|
|
168
|
-
return (await delegated.credential(
|
|
174
|
+
return (await delegated.credential(bound, scopes)).token;
|
|
169
175
|
}
|
|
170
176
|
if (scopes !== undefined) {
|
|
171
177
|
throw new AxiError("Application profiles use the configured Graph .default audience; delegated scopes are unavailable", "VALIDATION_ERROR", [
|
|
172
178
|
"mg-axi profile show --profile <name>",
|
|
173
179
|
]);
|
|
174
180
|
}
|
|
175
|
-
return (await application.credential(
|
|
181
|
+
return (await application.credential(bound)).token;
|
|
176
182
|
}
|
|
177
183
|
async function send(authorization, options) {
|
|
178
184
|
let handedOff = false;
|
|
@@ -240,8 +246,8 @@ export function createMutationSender(args) {
|
|
|
240
246
|
return sender;
|
|
241
247
|
}
|
|
242
248
|
function flushDirectory(path) {
|
|
243
|
-
// Node cannot open directory handles on Windows; journal
|
|
244
|
-
//
|
|
249
|
+
// Node cannot open directory handles on Windows; atomic journal writes
|
|
250
|
+
// still fsync the file before replacement.
|
|
245
251
|
if (process.platform === "win32")
|
|
246
252
|
return;
|
|
247
253
|
const fd = openSync(path, "r");
|
|
@@ -266,16 +272,16 @@ function serialize(value) {
|
|
|
266
272
|
}
|
|
267
273
|
}
|
|
268
274
|
export function createMutationCoordinator(args) {
|
|
269
|
-
const { profile, delegated, application, transport } = args;
|
|
275
|
+
const { profile, delegated, application, transport, files = new PrivateFiles() } = args;
|
|
270
276
|
const clock = args.clock ?? Date.now;
|
|
271
277
|
const journalPath = args.journalPath ?? resolveWriteLogPath();
|
|
272
|
-
const validated = profile.mode === "application" ? validateApplicationProfile(profile) : validateDelegatedProfile(profile);
|
|
278
|
+
const validated = profile.mode === "application" ? validateApplicationProfile(profile, files) : validateDelegatedProfile(profile, files);
|
|
273
279
|
const scope = Object.freeze({
|
|
274
280
|
tenantId: validated.tenantId,
|
|
275
281
|
allowWrites: validated.writes?.allowWrites === true,
|
|
276
282
|
operations: Object.freeze([...(validated.writes?.operations ?? [])]),
|
|
277
283
|
});
|
|
278
|
-
const sender = createMutationSender({ profile: validated, delegated, application, transport });
|
|
284
|
+
const sender = createMutationSender({ profile: validated, delegated, application, transport, files });
|
|
279
285
|
function enforceWriteGates(definition) {
|
|
280
286
|
// Gate 1: forced read-only overrides every profile.
|
|
281
287
|
// Gate 2: the profile must opt in through hand-edited configuration.
|
|
@@ -357,7 +363,7 @@ export function createMutationCoordinator(args) {
|
|
|
357
363
|
const line = JSON.stringify({ ...safe, intentKey: record.intentKey });
|
|
358
364
|
try {
|
|
359
365
|
const directory = dirname(journalPath);
|
|
360
|
-
const firstCreated =
|
|
366
|
+
const firstCreated = files.directory(directory);
|
|
361
367
|
if (firstCreated) {
|
|
362
368
|
let path = directory;
|
|
363
369
|
const parent = dirname(firstCreated);
|
|
@@ -367,48 +373,31 @@ export function createMutationCoordinator(args) {
|
|
|
367
373
|
}
|
|
368
374
|
flushDirectory(parent);
|
|
369
375
|
}
|
|
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
376
|
const lockPath = `${journalPath}.lock`;
|
|
380
377
|
const lock = openSync(lockPath, "wx", 0o600);
|
|
381
378
|
try {
|
|
382
|
-
|
|
383
|
-
const
|
|
384
|
-
|
|
385
|
-
if (
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
`Intent '${record.id}' requires manual reconciliation; read back its target and never resend it`,
|
|
401
|
-
]);
|
|
402
|
-
}
|
|
379
|
+
files.protect(lockPath);
|
|
380
|
+
const contents = existsSync(journalPath) ? files.read(journalPath) : "";
|
|
381
|
+
if (record.kind === "intent") {
|
|
382
|
+
if (contents && !contents.endsWith("\n"))
|
|
383
|
+
throw new Error("Incomplete mutation journal");
|
|
384
|
+
const records = contents.split("\n").filter(Boolean)
|
|
385
|
+
.map(entry => JSON.parse(entry));
|
|
386
|
+
if (records.some(entry => !entry || !["intent", "outcome"].includes(entry.kind)
|
|
387
|
+
|| typeof entry.intentKey !== "string" || !/^[a-f0-9]{64}$/.test(entry.intentKey))) {
|
|
388
|
+
throw new Error("Invalid mutation journal");
|
|
389
|
+
}
|
|
390
|
+
// No automatic replay: a recreated coordinator refuses an intent
|
|
391
|
+
// recorded without a terminal outcome, and a duplicate intent id
|
|
392
|
+
// is never reserved twice.
|
|
393
|
+
if (records.some(entry => entry.kind === "intent" && entry.intentKey === record.intentKey)) {
|
|
394
|
+
throw new AxiError(`blocked: intent '${record.id}' was already reserved`, "ALREADY_EXECUTED", [
|
|
395
|
+
`Intent '${record.id}' requires manual reconciliation; read back its target and never resend it`,
|
|
396
|
+
]);
|
|
403
397
|
}
|
|
404
|
-
writeFileSync(journal, `${line}\n`);
|
|
405
|
-
fsyncSync(journal);
|
|
406
|
-
if (created)
|
|
407
|
-
flushDirectory(directory);
|
|
408
|
-
}
|
|
409
|
-
finally {
|
|
410
|
-
closeSync(journal);
|
|
411
398
|
}
|
|
399
|
+
files.write(journalPath, `${contents}${line}\n`);
|
|
400
|
+
flushDirectory(directory);
|
|
412
401
|
}
|
|
413
402
|
finally {
|
|
414
403
|
closeSync(lock);
|
|
@@ -416,7 +405,7 @@ export function createMutationCoordinator(args) {
|
|
|
416
405
|
}
|
|
417
406
|
}
|
|
418
407
|
catch (error) {
|
|
419
|
-
if (error instanceof AxiError && error.code === "ALREADY_EXECUTED")
|
|
408
|
+
if (error instanceof AxiError && (error.code === "ALREADY_EXECUTED" || error.code === "CONFIG_INSECURE"))
|
|
420
409
|
throw error;
|
|
421
410
|
if (record.kind === "intent") {
|
|
422
411
|
throw new AxiError(`Mutation intent '${record.id}' could not be recorded`, "INTENT_NOT_RECORDED", [
|
package/dist/paths.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { isAbsolute, join, win32 } from "node:path";
|
|
3
|
+
export function toolDirectory(kind, env = process.env, platform = process.platform, home = homedir()) {
|
|
4
|
+
if (platform === "win32") {
|
|
5
|
+
const base = env[kind === "config" ? "APPDATA" : "LOCALAPPDATA"];
|
|
6
|
+
return win32.join(base || win32.join(home, "AppData", kind === "config" ? "Roaming" : "Local"), "mg-axi");
|
|
7
|
+
}
|
|
8
|
+
const base = env[kind === "config" ? "XDG_CONFIG_HOME" : "XDG_STATE_HOME"];
|
|
9
|
+
return join(base && isAbsolute(base) ? base : join(home, kind === "config" ? ".config" : ".local/state"), "mg-axi");
|
|
10
|
+
}
|
|
11
|
+
export function configPath(env = process.env) {
|
|
12
|
+
const override = env.MG_AXI_CONFIG?.trim();
|
|
13
|
+
return override ? override : join(toolDirectory("config", env), "config.json");
|
|
14
|
+
}
|
|
15
|
+
export function statePath(name, env = process.env) {
|
|
16
|
+
return join(toolDirectory("state", env), name);
|
|
17
|
+
}
|