tdoms-mcp 0.6.1
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/LICENSE +21 -0
- package/README.md +164 -0
- package/package.json +52 -0
- package/skills/tdoms-mcp-routing/SKILL.md +48 -0
- package/skills/tdoms-mcp-routing/agents/openai.yaml +4 -0
- package/src/capability-registry.js +124 -0
- package/src/client-manager.js +571 -0
- package/src/config.js +49 -0
- package/src/connection-store.js +126 -0
- package/src/index.js +79 -0
- package/src/mcp-server.js +2771 -0
- package/src/secret-store.js +109 -0
- package/src/storage-utils.js +176 -0
- package/src/tdoms-client.js +172 -0
- package/src/ui-page.js +408 -0
- package/src/web-app.js +127 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { atomicWriteFile, ensurePrivateDirectory, withFileLock } from "./storage-utils.js";
|
|
5
|
+
|
|
6
|
+
const KEY_FILE = "local.key";
|
|
7
|
+
const SECRET_FILE = "secrets.json";
|
|
8
|
+
const ALGORITHM = "aes-256-gcm";
|
|
9
|
+
|
|
10
|
+
export class SecretStore {
|
|
11
|
+
constructor(dir, { env = process.env } = {}) {
|
|
12
|
+
this.dir = dir;
|
|
13
|
+
this.env = env;
|
|
14
|
+
this.keyPath = path.join(dir, KEY_FILE);
|
|
15
|
+
this.secretPath = path.join(dir, SECRET_FILE);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
async getSecret(name) {
|
|
19
|
+
const secrets = await this.#readSecrets();
|
|
20
|
+
const encrypted = secrets[name];
|
|
21
|
+
if (!encrypted) {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const key = await this.#getKey();
|
|
26
|
+
const iv = Buffer.from(encrypted.iv, "base64");
|
|
27
|
+
const tag = Buffer.from(encrypted.tag, "base64");
|
|
28
|
+
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
|
|
29
|
+
decipher.setAuthTag(tag);
|
|
30
|
+
const decrypted = Buffer.concat([
|
|
31
|
+
decipher.update(Buffer.from(encrypted.ciphertext, "base64")),
|
|
32
|
+
decipher.final()
|
|
33
|
+
]);
|
|
34
|
+
return decrypted.toString("utf8");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async setSecret(name, value) {
|
|
38
|
+
await withFileLock(this.secretPath, async () => {
|
|
39
|
+
const secrets = await this.#readSecrets();
|
|
40
|
+
const key = await this.#getKey();
|
|
41
|
+
const iv = crypto.randomBytes(12);
|
|
42
|
+
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
|
|
43
|
+
const ciphertext = Buffer.concat([cipher.update(String(value), "utf8"), cipher.final()]);
|
|
44
|
+
secrets[name] = {
|
|
45
|
+
iv: iv.toString("base64"),
|
|
46
|
+
tag: cipher.getAuthTag().toString("base64"),
|
|
47
|
+
ciphertext: ciphertext.toString("base64"),
|
|
48
|
+
updatedAt: new Date().toISOString()
|
|
49
|
+
};
|
|
50
|
+
await this.#writeSecrets(secrets);
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async deleteSecret(name) {
|
|
55
|
+
await withFileLock(this.secretPath, async () => {
|
|
56
|
+
const secrets = await this.#readSecrets();
|
|
57
|
+
delete secrets[name];
|
|
58
|
+
await this.#writeSecrets(secrets);
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async #getKey() {
|
|
63
|
+
const configuredKey = parseMasterKey(this.env.TDOMS_MCP_MASTER_KEY);
|
|
64
|
+
if (configuredKey) return configuredKey;
|
|
65
|
+
|
|
66
|
+
await ensurePrivateDirectory(this.dir);
|
|
67
|
+
return withFileLock(this.keyPath, async () => {
|
|
68
|
+
try {
|
|
69
|
+
const stored = Buffer.from((await fs.readFile(this.keyPath, "utf8")).trim(), "base64");
|
|
70
|
+
return validateKey(stored, "local.key");
|
|
71
|
+
} catch (error) {
|
|
72
|
+
if (error.code !== "ENOENT") throw error;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const key = crypto.randomBytes(32);
|
|
76
|
+
await atomicWriteFile(this.keyPath, key.toString("base64"), { mode: 0o600 });
|
|
77
|
+
return key;
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async #readSecrets() {
|
|
82
|
+
try {
|
|
83
|
+
return JSON.parse(await fs.readFile(this.secretPath, "utf8"));
|
|
84
|
+
} catch (error) {
|
|
85
|
+
if (error.code === "ENOENT") {
|
|
86
|
+
return {};
|
|
87
|
+
}
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async #writeSecrets(secrets) {
|
|
93
|
+
await atomicWriteFile(this.secretPath, `${JSON.stringify(secrets, null, 2)}\n`, { mode: 0o600 });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function parseMasterKey(value) {
|
|
98
|
+
if (!value) return undefined;
|
|
99
|
+
const normalized = String(value).trim();
|
|
100
|
+
const encoding = /^[a-f0-9]{64}$/i.test(normalized) ? "hex" : "base64";
|
|
101
|
+
return validateKey(Buffer.from(normalized, encoding), "TDOMS_MCP_MASTER_KEY");
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function validateKey(key, source) {
|
|
105
|
+
if (key.length !== 32) {
|
|
106
|
+
throw new Error(`${source} must contain exactly 32 bytes encoded as base64 or 64 hexadecimal characters.`);
|
|
107
|
+
}
|
|
108
|
+
return key;
|
|
109
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import fs from "node:fs/promises";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
6
|
+
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
|
|
9
|
+
export async function ensurePrivateDirectory(dir) {
|
|
10
|
+
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
11
|
+
if (process.platform !== "win32") {
|
|
12
|
+
await fs.chmod(dir, 0o700);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function atomicWriteFile(filePath, data, { mode = 0o600 } = {}) {
|
|
17
|
+
const dir = path.dirname(filePath);
|
|
18
|
+
await ensurePrivateDirectory(dir);
|
|
19
|
+
const temporaryPath = path.join(dir, `.${path.basename(filePath)}.${process.pid}.${crypto.randomUUID()}.tmp`);
|
|
20
|
+
let handle;
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
handle = await fs.open(temporaryPath, "wx", mode);
|
|
24
|
+
await handle.writeFile(data, "utf8");
|
|
25
|
+
await handle.sync();
|
|
26
|
+
await handle.close();
|
|
27
|
+
handle = undefined;
|
|
28
|
+
await renameWithRetry(temporaryPath, filePath);
|
|
29
|
+
if (process.platform !== "win32") {
|
|
30
|
+
await fs.chmod(filePath, mode);
|
|
31
|
+
}
|
|
32
|
+
await syncDirectory(dir);
|
|
33
|
+
} finally {
|
|
34
|
+
await handle?.close().catch(() => {});
|
|
35
|
+
await fs.rm(temporaryPath, { force: true }).catch(() => {});
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function withFileLock(filePath, operation, {
|
|
40
|
+
timeoutMs = 10000,
|
|
41
|
+
staleMs = 30000,
|
|
42
|
+
retryMs = 20
|
|
43
|
+
} = {}) {
|
|
44
|
+
const lockPath = `${filePath}.lock`;
|
|
45
|
+
const token = `${process.pid}:${crypto.randomUUID()}`;
|
|
46
|
+
const startedAt = Date.now();
|
|
47
|
+
await ensurePrivateDirectory(path.dirname(filePath));
|
|
48
|
+
|
|
49
|
+
while (true) {
|
|
50
|
+
try {
|
|
51
|
+
const handle = await fs.open(lockPath, "wx", 0o600);
|
|
52
|
+
await handle.writeFile(token, "utf8");
|
|
53
|
+
await handle.close();
|
|
54
|
+
break;
|
|
55
|
+
} catch (error) {
|
|
56
|
+
const transientWindowsContention = process.platform === "win32" && ["EACCES", "EPERM"].includes(error.code);
|
|
57
|
+
if (error.code !== "EEXIST" && !transientWindowsContention) {
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (error.code === "EEXIST") {
|
|
62
|
+
await removeStaleLock(lockPath, staleMs);
|
|
63
|
+
}
|
|
64
|
+
if (Date.now() - startedAt >= timeoutMs) {
|
|
65
|
+
throw new Error(`Timed out waiting for local storage lock: ${lockPath}`);
|
|
66
|
+
}
|
|
67
|
+
await delay(retryMs);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
return await operation();
|
|
73
|
+
} finally {
|
|
74
|
+
await releaseOwnedLock(lockPath, token);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function hardenDataDirectory(dir, { platform = process.platform } = {}) {
|
|
79
|
+
await ensurePrivateDirectory(dir);
|
|
80
|
+
if (platform !== "win32") {
|
|
81
|
+
await fs.chmod(dir, 0o700);
|
|
82
|
+
for (const entry of await fs.readdir(dir, { withFileTypes: true })) {
|
|
83
|
+
if (entry.isFile()) {
|
|
84
|
+
await fs.chmod(path.join(dir, entry.name), 0o600);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return { platform, directoryMode: "0700", fileMode: "0600" };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const script = String.raw`
|
|
91
|
+
$target = $env:TDOMS_ACL_TARGET
|
|
92
|
+
$acl = New-Object System.Security.AccessControl.DirectorySecurity
|
|
93
|
+
$acl.SetAccessRuleProtection($true, $false)
|
|
94
|
+
$inheritance = [System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit'
|
|
95
|
+
$propagation = [System.Security.AccessControl.PropagationFlags]::None
|
|
96
|
+
$allow = [System.Security.AccessControl.AccessControlType]::Allow
|
|
97
|
+
$sids = @(
|
|
98
|
+
[System.Security.Principal.WindowsIdentity]::GetCurrent().User,
|
|
99
|
+
(New-Object System.Security.Principal.SecurityIdentifier('S-1-5-18')),
|
|
100
|
+
(New-Object System.Security.Principal.SecurityIdentifier('S-1-5-32-544'))
|
|
101
|
+
)
|
|
102
|
+
foreach ($sid in $sids) {
|
|
103
|
+
$rule = New-Object System.Security.AccessControl.FileSystemAccessRule($sid, 'FullControl', $inheritance, $propagation, $allow)
|
|
104
|
+
$acl.AddAccessRule($rule)
|
|
105
|
+
}
|
|
106
|
+
[System.IO.Directory]::SetAccessControl($target, $acl)
|
|
107
|
+
`;
|
|
108
|
+
await execFileAsync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], {
|
|
109
|
+
windowsHide: true,
|
|
110
|
+
env: { ...process.env, TDOMS_ACL_TARGET: dir }
|
|
111
|
+
});
|
|
112
|
+
return { platform, acl: "current-user, SYSTEM, Administrators" };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function removeStaleLock(lockPath, staleMs) {
|
|
116
|
+
try {
|
|
117
|
+
const stat = await fs.stat(lockPath);
|
|
118
|
+
if (Date.now() - stat.mtimeMs > staleMs) {
|
|
119
|
+
await fs.unlink(lockPath).catch((error) => {
|
|
120
|
+
if (error.code !== "ENOENT") throw error;
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
} catch (error) {
|
|
124
|
+
if (error.code !== "ENOENT" && !isTransientWindowsError(error)) {
|
|
125
|
+
throw error;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function releaseOwnedLock(lockPath, token) {
|
|
131
|
+
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
132
|
+
try {
|
|
133
|
+
if ((await fs.readFile(lockPath, "utf8")) === token) {
|
|
134
|
+
await fs.unlink(lockPath);
|
|
135
|
+
}
|
|
136
|
+
return;
|
|
137
|
+
} catch (error) {
|
|
138
|
+
if (error.code === "ENOENT") return;
|
|
139
|
+
if (!isTransientWindowsError(error) || attempt === 19) throw error;
|
|
140
|
+
await delay(10 + attempt * 5);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function isTransientWindowsError(error) {
|
|
146
|
+
return process.platform === "win32" && ["EACCES", "EBUSY", "EPERM"].includes(error.code);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function renameWithRetry(source, target) {
|
|
150
|
+
for (let attempt = 0; attempt < 20; attempt += 1) {
|
|
151
|
+
try {
|
|
152
|
+
await fs.rename(source, target);
|
|
153
|
+
return;
|
|
154
|
+
} catch (error) {
|
|
155
|
+
if (!isTransientWindowsError(error) || attempt === 19) throw error;
|
|
156
|
+
await delay(10 + attempt * 5);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function syncDirectory(dir) {
|
|
162
|
+
if (process.platform === "win32") return;
|
|
163
|
+
let handle;
|
|
164
|
+
try {
|
|
165
|
+
handle = await fs.open(dir, "r");
|
|
166
|
+
await handle.sync();
|
|
167
|
+
} catch {
|
|
168
|
+
// Some filesystems do not support syncing directory handles.
|
|
169
|
+
} finally {
|
|
170
|
+
await handle?.close().catch(() => {});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function delay(ms) {
|
|
175
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
176
|
+
}
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
import https from "node:https";
|
|
4
|
+
import nodePath from "node:path";
|
|
5
|
+
import tls from "node:tls";
|
|
6
|
+
import { normalizeBaseUrl } from "./config.js";
|
|
7
|
+
|
|
8
|
+
export class TdomsClient {
|
|
9
|
+
constructor(connection, {
|
|
10
|
+
httpRequest = http.request,
|
|
11
|
+
httpsRequest = https.request,
|
|
12
|
+
readFile = fs.readFile,
|
|
13
|
+
env = process.env
|
|
14
|
+
} = {}) {
|
|
15
|
+
this.connection = connection;
|
|
16
|
+
this.baseUrl = normalizeBaseUrl(connection);
|
|
17
|
+
this.httpRequest = httpRequest;
|
|
18
|
+
this.httpsRequest = httpsRequest;
|
|
19
|
+
this.readFile = readFile;
|
|
20
|
+
this.env = env;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async login(password = this.connection.password) {
|
|
24
|
+
if (!this.connection.username) {
|
|
25
|
+
throw new Error("TD/OMS username is required.");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (password === undefined) {
|
|
29
|
+
throw new Error("TD/OMS password is required for login.");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const response = await this.request("OMGAUTH", "/login", {
|
|
33
|
+
method: "POST",
|
|
34
|
+
body: {
|
|
35
|
+
email_or_username: this.connection.username,
|
|
36
|
+
password
|
|
37
|
+
},
|
|
38
|
+
token: this.connection.token
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
if (!response?.token) {
|
|
42
|
+
throw new Error("TD/OMS login response did not include a token.");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return response.token;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async getSystemInfo(token = this.connection.token, applications) {
|
|
49
|
+
const suffix = applications ? `/system?applications=${encodeURIComponent(applications)}` : "/system";
|
|
50
|
+
return this.request("OMSINFO", suffix, {
|
|
51
|
+
method: "GET",
|
|
52
|
+
token
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async getLicenseInfo() {
|
|
57
|
+
return this.request("OMSINFO", "/license", {
|
|
58
|
+
method: "GET"
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async get(service, path, token = this.connection.token) {
|
|
63
|
+
return this.request(service, path, {
|
|
64
|
+
method: "GET",
|
|
65
|
+
token
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async post(service, path, body, token = this.connection.token) {
|
|
70
|
+
return this.request(service, path, {
|
|
71
|
+
method: "POST",
|
|
72
|
+
body,
|
|
73
|
+
token
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async request(service, path, { method = "GET", body, token } = {}) {
|
|
78
|
+
const url = new URL(`${this.baseUrl}/${service}${path}`);
|
|
79
|
+
const payload = body === undefined ? undefined : JSON.stringify(body);
|
|
80
|
+
const headers = {
|
|
81
|
+
accept: "application/json"
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
if (payload !== undefined) {
|
|
85
|
+
headers["content-type"] = "application/json";
|
|
86
|
+
headers["content-length"] = Buffer.byteLength(payload);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (token) {
|
|
90
|
+
headers.authorization = `Bearer ${token}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const options = {
|
|
94
|
+
method,
|
|
95
|
+
headers
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
if (url.protocol === "https:") {
|
|
99
|
+
options.rejectUnauthorized = this.connection.verifyTls !== false;
|
|
100
|
+
const caFile = this.connection.caFile || this.env.TDOMS_MCP_CA_FILE;
|
|
101
|
+
if (caFile) {
|
|
102
|
+
const customCa = await this.readFile(nodePath.resolve(caFile), "utf8");
|
|
103
|
+
options.ca = [...tls.rootCertificates, customCa];
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const response = await sendRequest(
|
|
108
|
+
url.protocol === "https:" ? this.httpsRequest : this.httpRequest,
|
|
109
|
+
url,
|
|
110
|
+
options,
|
|
111
|
+
payload,
|
|
112
|
+
Number.parseInt(this.env.TDOMS_MCP_HTTP_TIMEOUT_MS || "60000", 10)
|
|
113
|
+
);
|
|
114
|
+
const text = response.text;
|
|
115
|
+
const parsed = text ? parseJsonResponse(text) : undefined;
|
|
116
|
+
|
|
117
|
+
if (response.statusCode < 200 || response.statusCode >= 300) {
|
|
118
|
+
const detail = typeof parsed === "object" ? JSON.stringify(parsed) : text;
|
|
119
|
+
throw new Error(`TD/OMS ${method} ${url.pathname} failed with ${response.statusCode}: ${detail}`);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return parsed;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function sendRequest(request, url, options, payload, timeoutMs) {
|
|
127
|
+
return new Promise((resolve, reject) => {
|
|
128
|
+
const req = request(url, options, (response) => {
|
|
129
|
+
const chunks = [];
|
|
130
|
+
response.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
|
|
131
|
+
response.on("end", () => resolve({
|
|
132
|
+
statusCode: response.statusCode || 0,
|
|
133
|
+
text: Buffer.concat(chunks).toString("utf8")
|
|
134
|
+
}));
|
|
135
|
+
response.on("error", reject);
|
|
136
|
+
});
|
|
137
|
+
req.on("error", reject);
|
|
138
|
+
req.setTimeout(timeoutMs, () => req.destroy(new Error(`TD/OMS request timed out after ${timeoutMs}ms.`)));
|
|
139
|
+
if (payload !== undefined) req.write(payload);
|
|
140
|
+
req.end();
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function parseJsonResponse(text) {
|
|
145
|
+
try {
|
|
146
|
+
return JSON.parse(text);
|
|
147
|
+
} catch {
|
|
148
|
+
return text;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function summarizeSystemInfo(systemInfo) {
|
|
153
|
+
const applications = Array.isArray(systemInfo?.applications) ? systemInfo.applications : [];
|
|
154
|
+
return {
|
|
155
|
+
isOMSManager: Boolean(systemInfo?.isOMSManager),
|
|
156
|
+
isSecurityManager: Boolean(systemInfo?.isSecurityManager),
|
|
157
|
+
isExitManager: Boolean(systemInfo?.isExitManager),
|
|
158
|
+
applicationCount: applications.length,
|
|
159
|
+
applications: applications.map((application) => ({
|
|
160
|
+
applicationCode: application.applicationCode,
|
|
161
|
+
applicationName: application.applicationName,
|
|
162
|
+
canCreateTasks: application.canCreateTasks,
|
|
163
|
+
canCreateRequests: application.canCreateRequests,
|
|
164
|
+
isUser: application.isUser,
|
|
165
|
+
isProgrammer: application.isProgrammer,
|
|
166
|
+
isManager: application.isManager,
|
|
167
|
+
developmentEnvironment: application.developmentEnvironment,
|
|
168
|
+
productionEnvironment: application.productionEnvironment,
|
|
169
|
+
emergencyEnvironment: application.emergencyEnvironment
|
|
170
|
+
}))
|
|
171
|
+
};
|
|
172
|
+
}
|