not-manage 0.2.1 → 0.2.3
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 +48 -12
- package/bin/not-manage.js +15 -2
- package/package.json +1 -2
- package/src/agent-input.js +101 -0
- package/src/cli-errors.js +109 -0
- package/src/cli.js +835 -64
- package/src/clio-api.js +108 -6
- package/src/commands-agent-context.js +119 -0
- package/src/commands-auth.js +336 -148
- package/src/commands-doctor.js +178 -0
- package/src/commands-request.js +144 -0
- package/src/compact-output.js +89 -0
- package/src/prompt.js +48 -0
- package/src/redaction.js +62 -25
- package/src/resource-command-runner.js +75 -4
- package/src/resource-handlers.js +15 -6
- package/src/resource-metadata.js +18 -0
- package/src/store.js +1 -1
- package/src/postinstall.js +0 -140
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
const { fetchWhoAmI, getValidAccessToken } = require("./clio-api");
|
|
2
|
+
const { CliError, UsageError } = require("./cli-errors");
|
|
3
|
+
const { findConfig, getTokenSet } = require("./store");
|
|
4
|
+
const { version } = require("../package.json");
|
|
5
|
+
|
|
6
|
+
const SAFE_READ_COMMAND = "not-manage users list --limit 1 --json";
|
|
7
|
+
|
|
8
|
+
function maskEmail(email) {
|
|
9
|
+
const text = String(email || "");
|
|
10
|
+
const atIndex = text.indexOf("@");
|
|
11
|
+
if (atIndex <= 1) {
|
|
12
|
+
return text ? "[redacted email]" : null;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
return `${text[0]}****${text.slice(atIndex)}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function tokenState(tokenSet) {
|
|
19
|
+
if (!tokenSet || !tokenSet.accessToken) {
|
|
20
|
+
return {
|
|
21
|
+
loggedIn: false,
|
|
22
|
+
status: "missing",
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const now = Math.floor(Date.now() / 1000);
|
|
27
|
+
const expiresAt = tokenSet.expiresAt || null;
|
|
28
|
+
const expiresSoon = expiresAt ? expiresAt <= now + 60 : false;
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
loggedIn: true,
|
|
32
|
+
source: tokenSet.source || "unknown",
|
|
33
|
+
status: expiresSoon ? "expires_soon" : "present",
|
|
34
|
+
expiresAt,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function configState(config) {
|
|
39
|
+
if (!config) {
|
|
40
|
+
return {
|
|
41
|
+
configured: false,
|
|
42
|
+
status: "missing",
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
configured: true,
|
|
48
|
+
source: config.source,
|
|
49
|
+
region: config.region,
|
|
50
|
+
regionLabel: config.regionLabel,
|
|
51
|
+
host: config.host,
|
|
52
|
+
redirectUri: config.redirectUri,
|
|
53
|
+
status: "present",
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function severity(report) {
|
|
58
|
+
if (report.errors.length > 0) {
|
|
59
|
+
return "error";
|
|
60
|
+
}
|
|
61
|
+
if (report.warnings.length > 0) {
|
|
62
|
+
return "warn";
|
|
63
|
+
}
|
|
64
|
+
return "ok";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function maybeFail(report, failOn) {
|
|
68
|
+
if (!failOn) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!["warn", "error"].includes(failOn)) {
|
|
73
|
+
throw new UsageError("`--fail-on` must be `warn` or `error`.", {
|
|
74
|
+
code: "invalid_fail_on",
|
|
75
|
+
hint: "not-manage doctor --help",
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const status = severity(report);
|
|
80
|
+
const shouldFail = failOn === "warn" ? status !== "ok" : status === "error";
|
|
81
|
+
if (!shouldFail) {
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
throw new CliError(`Doctor found ${status === "warn" ? "warnings" : "errors"}.`, {
|
|
86
|
+
code: `doctor_${status}`,
|
|
87
|
+
details: {
|
|
88
|
+
status,
|
|
89
|
+
warnings: report.warnings,
|
|
90
|
+
errors: report.errors,
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function printHuman(report) {
|
|
96
|
+
console.log(`not-manage v${report.version}`);
|
|
97
|
+
console.log(`Status: ${report.status}`);
|
|
98
|
+
console.log(`Config: ${report.config.status}`);
|
|
99
|
+
if (report.config.configured) {
|
|
100
|
+
console.log(`Region: ${report.config.region} (${report.config.regionLabel})`);
|
|
101
|
+
console.log(`Host: ${report.config.host}`);
|
|
102
|
+
console.log(`Redirect URI: ${report.config.redirectUri}`);
|
|
103
|
+
}
|
|
104
|
+
console.log(`Auth: ${report.auth.status}`);
|
|
105
|
+
if (report.api.status) {
|
|
106
|
+
console.log(`API: ${report.api.status}`);
|
|
107
|
+
}
|
|
108
|
+
report.warnings.forEach((warning) => console.log(`Warning: ${warning}`));
|
|
109
|
+
report.errors.forEach((error) => console.log(`Error: ${error}`));
|
|
110
|
+
report.suggestions.forEach((suggestion) => console.log(`Suggestion: ${suggestion}`));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function doctor(options = {}) {
|
|
114
|
+
const warnings = [];
|
|
115
|
+
const errors = [];
|
|
116
|
+
const suggestions = [];
|
|
117
|
+
const config = await findConfig();
|
|
118
|
+
const configReport = configState(config);
|
|
119
|
+
const tokenSet = await getTokenSet();
|
|
120
|
+
const authReport = tokenState(tokenSet);
|
|
121
|
+
const apiReport = {};
|
|
122
|
+
|
|
123
|
+
if (!config) {
|
|
124
|
+
warnings.push("Clio app credentials are not configured.");
|
|
125
|
+
suggestions.push("Run `not-manage auth setup`.");
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (config && !authReport.loggedIn) {
|
|
129
|
+
warnings.push("No local OAuth token was found.");
|
|
130
|
+
suggestions.push("Run `not-manage auth login`.");
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (config && authReport.loggedIn) {
|
|
134
|
+
try {
|
|
135
|
+
const accessToken = await getValidAccessToken(config, tokenSet);
|
|
136
|
+
const payload = await fetchWhoAmI(config, accessToken);
|
|
137
|
+
const user = payload?.data || payload;
|
|
138
|
+
apiReport.status = "reachable";
|
|
139
|
+
apiReport.user = {
|
|
140
|
+
id: user?.id || null,
|
|
141
|
+
name: user?.name || null,
|
|
142
|
+
email: maskEmail(user?.email),
|
|
143
|
+
};
|
|
144
|
+
suggestions.push(`Run \`${SAFE_READ_COMMAND}\` to verify a resource read.`);
|
|
145
|
+
} catch (error) {
|
|
146
|
+
apiReport.status = "failed";
|
|
147
|
+
errors.push(error.message);
|
|
148
|
+
suggestions.push("Run `not-manage auth login` if the token is expired or revoked.");
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const report = {
|
|
153
|
+
version,
|
|
154
|
+
status: "ok",
|
|
155
|
+
config: configReport,
|
|
156
|
+
auth: authReport,
|
|
157
|
+
api: apiReport,
|
|
158
|
+
warnings,
|
|
159
|
+
errors,
|
|
160
|
+
suggestions,
|
|
161
|
+
};
|
|
162
|
+
report.status = severity(report);
|
|
163
|
+
|
|
164
|
+
if (options.json) {
|
|
165
|
+
console.log(JSON.stringify(report, null, options.compact ? 0 : 2));
|
|
166
|
+
} else {
|
|
167
|
+
printHuman(report);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
maybeFail(report, options.failOn);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
module.exports = {
|
|
174
|
+
doctor,
|
|
175
|
+
__private: {
|
|
176
|
+
severity,
|
|
177
|
+
},
|
|
178
|
+
};
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
const fs = require("node:fs/promises");
|
|
2
|
+
|
|
3
|
+
const { __private, getValidAccessToken } = require("./clio-api");
|
|
4
|
+
const { UsageError } = require("./cli-errors");
|
|
5
|
+
const { maybeRedactPayload } = require("./redaction");
|
|
6
|
+
const { getConfig, getTokenSet } = require("./store");
|
|
7
|
+
|
|
8
|
+
const READ_METHODS = new Set(["GET", "HEAD"]);
|
|
9
|
+
const WRITE_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
10
|
+
|
|
11
|
+
function parseQuery(queryText) {
|
|
12
|
+
if (!queryText) {
|
|
13
|
+
return {};
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
return String(queryText)
|
|
17
|
+
.split(",")
|
|
18
|
+
.map((part) => part.trim())
|
|
19
|
+
.filter(Boolean)
|
|
20
|
+
.reduce((query, part) => {
|
|
21
|
+
const separatorIndex = part.search(/[:=]/);
|
|
22
|
+
if (separatorIndex <= 0) {
|
|
23
|
+
throw new UsageError("`--query` entries must look like `key=value`.", {
|
|
24
|
+
code: "invalid_query",
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
query[part.slice(0, separatorIndex).trim()] = part.slice(separatorIndex + 1).trim();
|
|
29
|
+
return query;
|
|
30
|
+
}, {});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function normalizePath(config, path, query) {
|
|
34
|
+
if (!path) {
|
|
35
|
+
throw new UsageError("Usage: not-manage request <method> <path> [options]", {
|
|
36
|
+
code: "missing_request_path",
|
|
37
|
+
hint: "not-manage request --help",
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const base = `https://${config.host}`;
|
|
42
|
+
const url = path.startsWith("http://") || path.startsWith("https://")
|
|
43
|
+
? path
|
|
44
|
+
: `${base}${path.startsWith("/") ? "" : "/"}${path}`;
|
|
45
|
+
|
|
46
|
+
const trusted = __private.parseTrustedApiUrl(config, url, "/api/v4/");
|
|
47
|
+
return __private.buildUrlWithQuery(trusted, query);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function readBody(bodyFile) {
|
|
51
|
+
if (!bodyFile) {
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const text = bodyFile === "-"
|
|
56
|
+
? await new Promise((resolve, reject) => {
|
|
57
|
+
let body = "";
|
|
58
|
+
process.stdin.setEncoding("utf8");
|
|
59
|
+
process.stdin.on("data", (chunk) => {
|
|
60
|
+
body += chunk;
|
|
61
|
+
});
|
|
62
|
+
process.stdin.on("end", () => resolve(body));
|
|
63
|
+
process.stdin.on("error", reject);
|
|
64
|
+
})
|
|
65
|
+
: await fs.readFile(bodyFile, "utf8");
|
|
66
|
+
|
|
67
|
+
return text ? JSON.parse(text) : undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function buildDryRunOutput(method, url, body) {
|
|
71
|
+
return {
|
|
72
|
+
dry_run: true,
|
|
73
|
+
would_send: {
|
|
74
|
+
method,
|
|
75
|
+
url: __private.sanitizeUrlForError(url),
|
|
76
|
+
write: WRITE_METHODS.has(method),
|
|
77
|
+
has_body: body !== undefined,
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function rawRequest(options = {}) {
|
|
83
|
+
const method = String(options.method || "").toUpperCase();
|
|
84
|
+
if (!READ_METHODS.has(method) && !WRITE_METHODS.has(method)) {
|
|
85
|
+
throw new UsageError("Usage: not-manage request <method> <path> [options]", {
|
|
86
|
+
code: "invalid_request_method",
|
|
87
|
+
hint: "Use GET, HEAD, POST, PUT, PATCH, or DELETE.",
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (WRITE_METHODS.has(method) && options.write !== true && options.dryRun !== true) {
|
|
92
|
+
throw new UsageError("Write methods require `--write`.", {
|
|
93
|
+
code: "write_confirmation_required",
|
|
94
|
+
hint: "Re-run with `--dry-run` to preview, or `--write` only when you intend to modify Clio data.",
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const config = await getConfig();
|
|
99
|
+
const url = normalizePath(config, options.path, parseQuery(options.query));
|
|
100
|
+
const body = await readBody(options.bodyFile);
|
|
101
|
+
|
|
102
|
+
if (options.dryRun === true) {
|
|
103
|
+
console.log(JSON.stringify(buildDryRunOutput(method, url, body), null, options.compact ? 0 : 2));
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const tokenSet = await getTokenSet();
|
|
108
|
+
const accessToken = await getValidAccessToken(config, tokenSet);
|
|
109
|
+
const response = await fetch(url, {
|
|
110
|
+
method,
|
|
111
|
+
headers: {
|
|
112
|
+
accept: "application/json",
|
|
113
|
+
authorization: `Bearer ${accessToken}`,
|
|
114
|
+
...(body === undefined ? {} : { "content-type": "application/json" }),
|
|
115
|
+
},
|
|
116
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
117
|
+
});
|
|
118
|
+
const text = method === "HEAD" ? "" : await response.text();
|
|
119
|
+
let payload = text;
|
|
120
|
+
|
|
121
|
+
if (text) {
|
|
122
|
+
try {
|
|
123
|
+
payload = JSON.parse(text);
|
|
124
|
+
} catch (_error) {
|
|
125
|
+
payload = { body: text };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const output = {
|
|
130
|
+
status: response.status,
|
|
131
|
+
ok: response.ok,
|
|
132
|
+
data: maybeRedactPayload(payload, options, "request"),
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
console.log(JSON.stringify(output, null, options.compact ? 0 : 2));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
module.exports = {
|
|
139
|
+
rawRequest,
|
|
140
|
+
__private: {
|
|
141
|
+
buildDryRunOutput,
|
|
142
|
+
parseQuery,
|
|
143
|
+
},
|
|
144
|
+
};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
const COMPACT_KEYS = new Set([
|
|
2
|
+
"id",
|
|
3
|
+
"data",
|
|
4
|
+
"name",
|
|
5
|
+
"first_name",
|
|
6
|
+
"last_name",
|
|
7
|
+
"email",
|
|
8
|
+
"type",
|
|
9
|
+
"status",
|
|
10
|
+
"state",
|
|
11
|
+
"priority",
|
|
12
|
+
"date",
|
|
13
|
+
"due_at",
|
|
14
|
+
"start_at",
|
|
15
|
+
"end_at",
|
|
16
|
+
"created_at",
|
|
17
|
+
"updated_at",
|
|
18
|
+
"display_number",
|
|
19
|
+
"number",
|
|
20
|
+
"subject",
|
|
21
|
+
"summary",
|
|
22
|
+
"description",
|
|
23
|
+
"balance",
|
|
24
|
+
"total",
|
|
25
|
+
"paid",
|
|
26
|
+
"pending",
|
|
27
|
+
"matter",
|
|
28
|
+
"client",
|
|
29
|
+
"contact",
|
|
30
|
+
"user",
|
|
31
|
+
"assignee",
|
|
32
|
+
"assigner",
|
|
33
|
+
"responsible_attorney",
|
|
34
|
+
"responsible_staff",
|
|
35
|
+
"originating_attorney",
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
const NESTED_KEYS = new Set([
|
|
39
|
+
"id",
|
|
40
|
+
"name",
|
|
41
|
+
"first_name",
|
|
42
|
+
"last_name",
|
|
43
|
+
"email",
|
|
44
|
+
"type",
|
|
45
|
+
"status",
|
|
46
|
+
"state",
|
|
47
|
+
"display_number",
|
|
48
|
+
"number",
|
|
49
|
+
"description",
|
|
50
|
+
]);
|
|
51
|
+
|
|
52
|
+
function isPlainObject(value) {
|
|
53
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function compactObject(object, allowedKeys) {
|
|
57
|
+
return Object.entries(object).reduce((output, [key, value]) => {
|
|
58
|
+
if (!allowedKeys.has(key)) {
|
|
59
|
+
return output;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
output[key] = compactValue(value, key === "data" ? COMPACT_KEYS : NESTED_KEYS);
|
|
63
|
+
return output;
|
|
64
|
+
}, {});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function compactValue(value, allowedKeys = COMPACT_KEYS) {
|
|
68
|
+
if (Array.isArray(value)) {
|
|
69
|
+
return value.map((item) => compactValue(item, allowedKeys));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (!isPlainObject(value)) {
|
|
73
|
+
return value;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const compacted = compactObject(value, allowedKeys);
|
|
77
|
+
return Object.keys(compacted).length > 0 ? compacted : value;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function maybeCompactPayload(payload, options = {}) {
|
|
81
|
+
return options.compact ? compactValue(payload) : payload;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
module.exports = {
|
|
85
|
+
maybeCompactPayload,
|
|
86
|
+
__private: {
|
|
87
|
+
compactValue,
|
|
88
|
+
},
|
|
89
|
+
};
|
package/src/prompt.js
CHANGED
|
@@ -2,6 +2,10 @@ const { Writable } = require("node:stream");
|
|
|
2
2
|
const { createInterface } = require("node:readline/promises");
|
|
3
3
|
const { stdin, stdout } = require("node:process");
|
|
4
4
|
|
|
5
|
+
const isTTYColor = stdout.isTTY && !process.env.NO_COLOR;
|
|
6
|
+
const bold = (text) => (isTTYColor ? `\x1b[1m${text}\x1b[22m` : text);
|
|
7
|
+
const dim = (text) => (isTTYColor ? `\x1b[2m${text}\x1b[22m` : text);
|
|
8
|
+
|
|
5
9
|
class PromptOutput extends Writable {
|
|
6
10
|
constructor(target) {
|
|
7
11
|
super();
|
|
@@ -94,10 +98,54 @@ async function askSecret(rl, label) {
|
|
|
94
98
|
}
|
|
95
99
|
}
|
|
96
100
|
|
|
101
|
+
async function selectOption(_rl, label, options, defaultIndex = 0) {
|
|
102
|
+
if (!Array.isArray(options) || options.length === 0) {
|
|
103
|
+
throw new Error(`No options available for ${label}.`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (!stdin.isTTY) {
|
|
107
|
+
return options[defaultIndex].value;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
console.log(bold(label));
|
|
111
|
+
options.forEach((option, index) => {
|
|
112
|
+
const marker = index === defaultIndex ? "*" : " ";
|
|
113
|
+
stdout.write(` ${marker} ${index + 1}. ${option.label}\n`);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
const fallback = String(defaultIndex + 1);
|
|
117
|
+
|
|
118
|
+
while (true) {
|
|
119
|
+
const answer = String(await ask(_rl, "Choose an option", fallback)).trim();
|
|
120
|
+
const numericIndex = Number.parseInt(answer, 10);
|
|
121
|
+
|
|
122
|
+
if (Number.isInteger(numericIndex) && numericIndex >= 1 && numericIndex <= options.length) {
|
|
123
|
+
return options[numericIndex - 1].value;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const normalized = answer.toLowerCase();
|
|
127
|
+
const matchingOption = options.find((option) => {
|
|
128
|
+
return (
|
|
129
|
+
String(option.value).trim().toLowerCase() === normalized ||
|
|
130
|
+
String(option.label).trim().toLowerCase() === normalized
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
if (matchingOption) {
|
|
135
|
+
return matchingOption.value;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
console.log(`Enter a number from 1 to ${options.length}, or one of the listed values.`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
97
142
|
module.exports = {
|
|
98
143
|
PromptOutput,
|
|
99
144
|
ask,
|
|
100
145
|
askSecret,
|
|
146
|
+
bold,
|
|
101
147
|
decodePromptChunk,
|
|
148
|
+
dim,
|
|
149
|
+
selectOption,
|
|
102
150
|
withPrompt,
|
|
103
151
|
};
|
package/src/redaction.js
CHANGED
|
@@ -71,6 +71,7 @@ const NAME_HEURISTIC_EXCLUDED_TOKENS = new Set([
|
|
|
71
71
|
]);
|
|
72
72
|
|
|
73
73
|
const PLACEHOLDERS = {
|
|
74
|
+
creditCard: "[REDACTED_CREDIT_CARD]",
|
|
74
75
|
email: "[REDACTED_EMAIL]",
|
|
75
76
|
name: "[REDACTED_NAME]",
|
|
76
77
|
phone: "[REDACTED_PHONE]",
|
|
@@ -78,6 +79,11 @@ const PLACEHOLDERS = {
|
|
|
78
79
|
taxId: "[REDACTED_TAX_ID]",
|
|
79
80
|
};
|
|
80
81
|
const PERSON_NAME_SUFFIXES = new Set(["esq", "ii", "iii", "iv", "jr", "sr"]);
|
|
82
|
+
const COMPANY_NOISE_TOKENS = new Set([
|
|
83
|
+
"and", "co", "company", "corp", "corporation", "dba", "group",
|
|
84
|
+
"inc", "incorporated", "limited", "llc", "llp", "lp", "ltd",
|
|
85
|
+
"of", "pa", "pc", "plc", "pllc", "the",
|
|
86
|
+
]);
|
|
81
87
|
|
|
82
88
|
function escapeRegExp(value) {
|
|
83
89
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
@@ -116,13 +122,19 @@ function collectContactLikeReplacements(node, replacements, dedupe) {
|
|
|
116
122
|
|
|
117
123
|
pushReplacement(replacements, dedupe, node.name, PLACEHOLDERS.name);
|
|
118
124
|
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
.trim();
|
|
125
|
+
const firstName = normalizeString(node.first_name);
|
|
126
|
+
const lastName = normalizeString(node.last_name);
|
|
127
|
+
|
|
128
|
+
const fullName = [firstName, lastName].filter(Boolean).join(" ").trim();
|
|
124
129
|
pushReplacement(replacements, dedupe, fullName, PLACEHOLDERS.name);
|
|
125
130
|
|
|
131
|
+
if (lastName && lastName.length >= 2) {
|
|
132
|
+
pushReplacement(replacements, dedupe, lastName, PLACEHOLDERS.name);
|
|
133
|
+
}
|
|
134
|
+
if (firstName && firstName.length >= 2) {
|
|
135
|
+
pushReplacement(replacements, dedupe, firstName, PLACEHOLDERS.name);
|
|
136
|
+
}
|
|
137
|
+
|
|
126
138
|
EMAIL_FIELDS.forEach((field) => {
|
|
127
139
|
pushReplacement(replacements, dedupe, node[field], PLACEHOLDERS.email);
|
|
128
140
|
});
|
|
@@ -220,11 +232,15 @@ function collectSafeIdentityNames(
|
|
|
220
232
|
return preserved;
|
|
221
233
|
}
|
|
222
234
|
|
|
223
|
-
function
|
|
224
|
-
|
|
225
|
-
.split(
|
|
235
|
+
function tokenizeName(name, separator = /\s+/) {
|
|
236
|
+
return normalizeString(name)
|
|
237
|
+
.split(separator)
|
|
226
238
|
.map((token) => token.replace(/^[^A-Za-z]+|[^A-Za-z]+$/g, ""))
|
|
227
239
|
.filter(Boolean);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function derivePersonSurname(name) {
|
|
243
|
+
const tokens = tokenizeName(name);
|
|
228
244
|
|
|
229
245
|
if (tokens.length < 2) {
|
|
230
246
|
return "";
|
|
@@ -238,7 +254,15 @@ function derivePersonSurname(name) {
|
|
|
238
254
|
return index > 0 ? tokens[index] : "";
|
|
239
255
|
}
|
|
240
256
|
|
|
241
|
-
function
|
|
257
|
+
function deriveCompanyNameTokens(name) {
|
|
258
|
+
return tokenizeName(name, /[\s&,]+/).filter(
|
|
259
|
+
(token) =>
|
|
260
|
+
token.length >= 3 &&
|
|
261
|
+
!COMPANY_NOISE_TOKENS.has(token.toLowerCase())
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function collectClientLabelReplacements(
|
|
242
266
|
value,
|
|
243
267
|
policy,
|
|
244
268
|
clientContext = false,
|
|
@@ -247,7 +271,7 @@ function collectPersonClientLabelReplacements(
|
|
|
247
271
|
) {
|
|
248
272
|
if (Array.isArray(value)) {
|
|
249
273
|
value.forEach((item) => {
|
|
250
|
-
|
|
274
|
+
collectClientLabelReplacements(
|
|
251
275
|
item,
|
|
252
276
|
policy,
|
|
253
277
|
clientContext,
|
|
@@ -271,8 +295,14 @@ function collectPersonClientLabelReplacements(
|
|
|
271
295
|
);
|
|
272
296
|
}
|
|
273
297
|
|
|
298
|
+
if (clientContext && value.type === "Company") {
|
|
299
|
+
deriveCompanyNameTokens(value.name).forEach((token) => {
|
|
300
|
+
pushReplacement(replacements, dedupe, token, PLACEHOLDERS.name);
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
|
|
274
304
|
Object.entries(value).forEach(([key, child]) => {
|
|
275
|
-
|
|
305
|
+
collectClientLabelReplacements(
|
|
276
306
|
child,
|
|
277
307
|
policy,
|
|
278
308
|
clientContext || policy.clientObjectKeys.has(key),
|
|
@@ -284,14 +314,19 @@ function collectPersonClientLabelReplacements(
|
|
|
284
314
|
return replacements;
|
|
285
315
|
}
|
|
286
316
|
|
|
287
|
-
function
|
|
317
|
+
function applyReplacements(text, replacements, wordBoundary = false) {
|
|
288
318
|
return replacements
|
|
289
319
|
.slice()
|
|
290
320
|
.sort((left, right) => right.value.length - left.value.length)
|
|
291
321
|
.reduce((output, replacement) => {
|
|
292
|
-
const
|
|
293
|
-
|
|
294
|
-
|
|
322
|
+
const escaped = escapeRegExp(replacement.value);
|
|
323
|
+
const pattern = wordBoundary ? `\\b${escaped}\\b` : escaped;
|
|
324
|
+
return output.replace(new RegExp(pattern, "gi"), replacement.placeholder);
|
|
325
|
+
}, String(text));
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function replaceKnownSensitiveValues(text, replacements) {
|
|
329
|
+
return applyReplacements(text, replacements);
|
|
295
330
|
}
|
|
296
331
|
|
|
297
332
|
function redactPatternPii(text) {
|
|
@@ -304,8 +339,16 @@ function redactPatternPii(text) {
|
|
|
304
339
|
/\b(?:\+?1[-.\s]*)?(?:\(\d{3}\)|\d{3})[-.\s]*\d{3}[-.\s]*\d{4}\b/g,
|
|
305
340
|
PLACEHOLDERS.phone
|
|
306
341
|
);
|
|
307
|
-
output = output.replace(/\b\d{3}-\d{2}-\d{4}\b/g, PLACEHOLDERS.ssn);
|
|
342
|
+
output = output.replace(/\b\d{3}[-\s]\d{2}[-\s]\d{4}\b/g, PLACEHOLDERS.ssn);
|
|
308
343
|
output = output.replace(/\b\d{2}-\d{7}\b/g, PLACEHOLDERS.taxId);
|
|
344
|
+
output = output.replace(
|
|
345
|
+
/\b\d{4}[-\s]?\d{4}[-\s]?\d{4}[-\s]?\d{4}\b/g,
|
|
346
|
+
PLACEHOLDERS.creditCard
|
|
347
|
+
);
|
|
348
|
+
output = output.replace(
|
|
349
|
+
/\b3[47]\d{2}[-\s]?\d{6}[-\s]?\d{5}\b/g,
|
|
350
|
+
PLACEHOLDERS.creditCard
|
|
351
|
+
);
|
|
309
352
|
|
|
310
353
|
return output;
|
|
311
354
|
}
|
|
@@ -387,13 +430,7 @@ function redactLikelyBareNames(text, preservedNames) {
|
|
|
387
430
|
}
|
|
388
431
|
|
|
389
432
|
function replaceMatterLabelDerivedNames(text, replacements) {
|
|
390
|
-
return replacements
|
|
391
|
-
.slice()
|
|
392
|
-
.sort((left, right) => right.value.length - left.value.length)
|
|
393
|
-
.reduce((output, replacement) => {
|
|
394
|
-
const matcher = new RegExp(`\\b${escapeRegExp(replacement.value)}\\b`, "gi");
|
|
395
|
-
return output.replace(matcher, replacement.placeholder);
|
|
396
|
-
}, String(text));
|
|
433
|
+
return applyReplacements(text, replacements, true);
|
|
397
434
|
}
|
|
398
435
|
|
|
399
436
|
function isMatterLabelContext(policy, path, key) {
|
|
@@ -516,7 +553,7 @@ function redactValue(
|
|
|
516
553
|
function redactPayload(value, resourceType) {
|
|
517
554
|
const policy = getRedactionPolicy(resourceType);
|
|
518
555
|
const replacements = collectSensitiveReplacements(value, resourceType);
|
|
519
|
-
const derivedLabelReplacements =
|
|
556
|
+
const derivedLabelReplacements = collectClientLabelReplacements(value, policy);
|
|
520
557
|
const preservedNames = collectSafeIdentityNames(
|
|
521
558
|
value,
|
|
522
559
|
policy,
|
|
@@ -557,7 +594,7 @@ module.exports = {
|
|
|
557
594
|
maybeRedactData,
|
|
558
595
|
maybeRedactPayload,
|
|
559
596
|
__private: {
|
|
560
|
-
|
|
597
|
+
collectClientLabelReplacements,
|
|
561
598
|
collectSafeIdentityNames,
|
|
562
599
|
derivePersonSurname,
|
|
563
600
|
redactLikelyBareNames,
|