@nifrajs/link-safety-typesafe 0.1.0
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/index.d.ts +14 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +154 -0
- package/dist/index.js.map +1 -0
- package/package.json +48 -0
- package/src/index.ts +201 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { DecisionProvider } from "@nifrajs/decision";
|
|
2
|
+
import type { LinkClassifier } from "@nifrajs/link-safety";
|
|
3
|
+
export interface TypeSafeLinkClassifierOptions {
|
|
4
|
+
readonly apiKey?: string;
|
|
5
|
+
readonly model?: string;
|
|
6
|
+
readonly baseUrl?: string;
|
|
7
|
+
readonly timeoutMs?: number;
|
|
8
|
+
readonly maxResponseBytes?: number;
|
|
9
|
+
readonly minimumConfidence?: number;
|
|
10
|
+
readonly exampleLimit?: number;
|
|
11
|
+
readonly provider?: DecisionProvider;
|
|
12
|
+
}
|
|
13
|
+
export declare function createTypeSafeLinkClassifier(options: TypeSafeLinkClassifierOptions): LinkClassifier;
|
|
14
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAqB,gBAAgB,EAAE,MAAM,mBAAmB,CAAC;AAI7E,OAAO,KAAK,EAEV,cAAc,EAKf,MAAM,sBAAsB,CAAC;AAqC9B,MAAM,WAAW,6BAA6B;IAC5C,QAAQ,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,gBAAgB,CAAC,EAAE,MAAM,CAAC;IACnC,QAAQ,CAAC,iBAAiB,CAAC,EAAE,MAAM,CAAC;IACpC,QAAQ,CAAC,YAAY,CAAC,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,QAAQ,CAAC,EAAE,gBAAgB,CAAC;CACtC;AAED,wBAAgB,4BAA4B,CAC1C,OAAO,EAAE,6BAA6B,GACrC,cAAc,CAsDhB"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { choice, defineDecision, score } from "@nifrajs/decision";
|
|
2
|
+
import { createTypeSafeProvider } from "@nifrajs/decision-typesafe";
|
|
3
|
+
import { normalizeLink } from "@nifrajs/link-safety";
|
|
4
|
+
import { t } from "@nifrajs/schema";
|
|
5
|
+
const DEFAULT_MINIMUM_CONFIDENCE = 0.92;
|
|
6
|
+
const MAX_EXAMPLES = 8;
|
|
7
|
+
const linkSafetyState = t.object({
|
|
8
|
+
protocol: t.string({ minLength: 1, maxLength: 5 }),
|
|
9
|
+
hostname: t.string({ minLength: 1, maxLength: 253 }),
|
|
10
|
+
pathname: t.string({ minLength: 1, maxLength: 2048 }),
|
|
11
|
+
featureSummary: t.string({ minLength: 2, maxLength: 8192 }),
|
|
12
|
+
corpusEvidence: t.string({ minLength: 2, maxLength: 16_384 }),
|
|
13
|
+
});
|
|
14
|
+
const linkSafetyDecision = defineDecision({
|
|
15
|
+
name: "link.safety",
|
|
16
|
+
version: "1.0.0",
|
|
17
|
+
state: linkSafetyState,
|
|
18
|
+
questions: {
|
|
19
|
+
verdict: choice({
|
|
20
|
+
instructions: "Classify the normalized URL as benign, suspicious, or malicious. Use suspicious when evidence is mixed or incomplete.",
|
|
21
|
+
criteria: {
|
|
22
|
+
benign: "No meaningful abuse signal is present.",
|
|
23
|
+
suspicious: "The URL needs review because signals are ambiguous or risky.",
|
|
24
|
+
malicious: "The URL strongly indicates phishing, malware, or another abuse pattern.",
|
|
25
|
+
},
|
|
26
|
+
}),
|
|
27
|
+
risk: score({
|
|
28
|
+
instructions: "Score the operational risk of allowing this URL to proceed.",
|
|
29
|
+
criteria: ["none", "low", "moderate", "high", "critical"],
|
|
30
|
+
}),
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
export function createTypeSafeLinkClassifier(options) {
|
|
34
|
+
const minimumConfidence = options.minimumConfidence ?? DEFAULT_MINIMUM_CONFIDENCE;
|
|
35
|
+
if (!Number.isFinite(minimumConfidence) ||
|
|
36
|
+
minimumConfidence < 0 ||
|
|
37
|
+
minimumConfidence > 1) {
|
|
38
|
+
throw new RangeError("typesafe link classifier: minimumConfidence is invalid");
|
|
39
|
+
}
|
|
40
|
+
const exampleLimit = options.exampleLimit ?? MAX_EXAMPLES;
|
|
41
|
+
if (!Number.isSafeInteger(exampleLimit) ||
|
|
42
|
+
exampleLimit < 0 ||
|
|
43
|
+
exampleLimit > MAX_EXAMPLES) {
|
|
44
|
+
throw new RangeError("typesafe link classifier: exampleLimit is invalid");
|
|
45
|
+
}
|
|
46
|
+
const provider = options.provider ?? createProvider(options);
|
|
47
|
+
return Object.freeze({
|
|
48
|
+
id: "typesafe",
|
|
49
|
+
classify: async (input) => {
|
|
50
|
+
if (input.signal.aborted)
|
|
51
|
+
return { ok: false, error: "cancelled" };
|
|
52
|
+
const state = createState(input, exampleLimit);
|
|
53
|
+
const result = await linkSafetyDecision.evaluate(state, {
|
|
54
|
+
provider,
|
|
55
|
+
signal: input.signal,
|
|
56
|
+
policy: {
|
|
57
|
+
allowAct: true,
|
|
58
|
+
minimumConfidence,
|
|
59
|
+
decide: (answers) => answers.verdict.choice === "suspicious" ? "review" : "act",
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
if (!result.ok)
|
|
63
|
+
return { ok: false, error: mapDecisionError(result.error.code) };
|
|
64
|
+
const riskLevels = linkSafetyDecision.questions.risk.criteria;
|
|
65
|
+
const riskScore = result.answers.risk.score / Math.max(1, riskLevels.length - 1);
|
|
66
|
+
const reasons = ["semantic_classifier"];
|
|
67
|
+
return {
|
|
68
|
+
ok: true,
|
|
69
|
+
verdict: result.answers.verdict.choice,
|
|
70
|
+
riskScore,
|
|
71
|
+
confidence: Math.min(result.answers.verdict.confidence, result.answers.risk.confidence),
|
|
72
|
+
reasons,
|
|
73
|
+
provider: result.metadata.provider,
|
|
74
|
+
...(result.metadata.model === undefined
|
|
75
|
+
? {}
|
|
76
|
+
: { model: result.metadata.model }),
|
|
77
|
+
};
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
function createProvider(options) {
|
|
82
|
+
if (typeof options.apiKey !== "string" ||
|
|
83
|
+
options.apiKey.trim() === "" ||
|
|
84
|
+
typeof options.model !== "string" ||
|
|
85
|
+
options.model.trim() === "") {
|
|
86
|
+
throw new TypeError("typesafe link classifier: apiKey and model are required without a provider");
|
|
87
|
+
}
|
|
88
|
+
const providerOptions = {
|
|
89
|
+
apiKey: options.apiKey,
|
|
90
|
+
model: options.model,
|
|
91
|
+
...(options.baseUrl === undefined ? {} : { baseUrl: options.baseUrl }),
|
|
92
|
+
...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
|
|
93
|
+
...(options.maxResponseBytes === undefined
|
|
94
|
+
? {}
|
|
95
|
+
: { maxResponseBytes: options.maxResponseBytes }),
|
|
96
|
+
};
|
|
97
|
+
return createTypeSafeProvider(providerOptions);
|
|
98
|
+
}
|
|
99
|
+
function createState(input, exampleLimit) {
|
|
100
|
+
const featureSummary = JSON.stringify({
|
|
101
|
+
protocol: input.link.protocol,
|
|
102
|
+
hostname: input.link.hostname,
|
|
103
|
+
pathname: input.link.pathname,
|
|
104
|
+
flags: input.link.flags,
|
|
105
|
+
hasQuery: input.link.hasQuery,
|
|
106
|
+
queryParameterCount: input.link.queryParameterCount,
|
|
107
|
+
queryLength: input.link.queryLength,
|
|
108
|
+
});
|
|
109
|
+
const corpusEvidence = JSON.stringify({
|
|
110
|
+
matches: input.matches.map((match) => serializeEntry(match.entry)),
|
|
111
|
+
examples: input.examples.slice(0, exampleLimit).map(serializeEntry),
|
|
112
|
+
});
|
|
113
|
+
return {
|
|
114
|
+
protocol: input.link.protocol,
|
|
115
|
+
hostname: input.link.hostname,
|
|
116
|
+
pathname: input.link.pathname,
|
|
117
|
+
featureSummary,
|
|
118
|
+
corpusEvidence,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
function serializeEntry(entry) {
|
|
122
|
+
const safeIndicator = entry.indicatorType === "url_prefix"
|
|
123
|
+
? normalizeUrlIndicator(entry.indicator)
|
|
124
|
+
: entry.indicator;
|
|
125
|
+
return {
|
|
126
|
+
id: entry.id,
|
|
127
|
+
indicatorType: entry.indicatorType,
|
|
128
|
+
indicator: safeIndicator,
|
|
129
|
+
label: entry.label,
|
|
130
|
+
...(entry.category === undefined ? {} : { category: entry.category }),
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
function normalizeUrlIndicator(value) {
|
|
134
|
+
const normalized = normalizeLink(value);
|
|
135
|
+
return normalized.ok ? normalized.link.canonical : "invalid-indicator";
|
|
136
|
+
}
|
|
137
|
+
function mapDecisionError(code) {
|
|
138
|
+
if (code === "cancelled")
|
|
139
|
+
return "cancelled";
|
|
140
|
+
if (code === "provider_timeout")
|
|
141
|
+
return "classifier_timeout";
|
|
142
|
+
if (code === "provider_rate_limited")
|
|
143
|
+
return "classifier_rate_limited";
|
|
144
|
+
if (code === "provider_unauthorized")
|
|
145
|
+
return "classifier_unauthorized";
|
|
146
|
+
if (code === "provider_bad_request")
|
|
147
|
+
return "classifier_response_invalid";
|
|
148
|
+
if (code === "provider_response_invalid")
|
|
149
|
+
return "classifier_response_invalid";
|
|
150
|
+
if (code === "provider_unavailable")
|
|
151
|
+
return "classifier_unavailable";
|
|
152
|
+
return "classifier_response_invalid";
|
|
153
|
+
}
|
|
154
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nifrajs/link-safety-typesafe",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Jev/TypeSafe classifier for Nifra Link Safety.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"nifra",
|
|
7
|
+
"typesafe",
|
|
8
|
+
"jev",
|
|
9
|
+
"url",
|
|
10
|
+
"phishing",
|
|
11
|
+
"security"
|
|
12
|
+
],
|
|
13
|
+
"type": "module",
|
|
14
|
+
"main": "./dist/index.js",
|
|
15
|
+
"module": "./dist/index.js",
|
|
16
|
+
"types": "./dist/index.d.ts",
|
|
17
|
+
"exports": {
|
|
18
|
+
".": {
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"import": "./dist/index.js",
|
|
21
|
+
"default": "./dist/index.js"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"dist",
|
|
26
|
+
"src"
|
|
27
|
+
],
|
|
28
|
+
"license": "MIT",
|
|
29
|
+
"publishConfig": {
|
|
30
|
+
"access": "public"
|
|
31
|
+
},
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/nifrajs/nifra-link-safety.git",
|
|
35
|
+
"directory": "packages/link-safety-typesafe"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"@nifrajs/core": "^3.3.0",
|
|
39
|
+
"@nifrajs/decision": "^0.1.0",
|
|
40
|
+
"@nifrajs/decision-typesafe": "^0.1.0",
|
|
41
|
+
"@nifrajs/schema": "^3.3.0",
|
|
42
|
+
"@nifrajs/link-safety": "^0.1.0"
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"build": "tsc -p tsconfig.build.json",
|
|
46
|
+
"test": "bun test"
|
|
47
|
+
}
|
|
48
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import type { DecisionErrorCode, DecisionProvider } from "@nifrajs/decision";
|
|
2
|
+
import { choice, defineDecision, score } from "@nifrajs/decision";
|
|
3
|
+
import type { TypeSafeProviderOptions } from "@nifrajs/decision-typesafe";
|
|
4
|
+
import { createTypeSafeProvider } from "@nifrajs/decision-typesafe";
|
|
5
|
+
import type {
|
|
6
|
+
CorpusEntry,
|
|
7
|
+
LinkClassifier,
|
|
8
|
+
LinkClassifierInput,
|
|
9
|
+
LinkClassifierResult,
|
|
10
|
+
LinkSafetyErrorCode,
|
|
11
|
+
LinkSafetyReason,
|
|
12
|
+
} from "@nifrajs/link-safety";
|
|
13
|
+
import { normalizeLink } from "@nifrajs/link-safety";
|
|
14
|
+
import { t } from "@nifrajs/schema";
|
|
15
|
+
|
|
16
|
+
const DEFAULT_MINIMUM_CONFIDENCE = 0.92;
|
|
17
|
+
const MAX_EXAMPLES = 8;
|
|
18
|
+
|
|
19
|
+
const linkSafetyState = t.object({
|
|
20
|
+
protocol: t.string({ minLength: 1, maxLength: 5 }),
|
|
21
|
+
hostname: t.string({ minLength: 1, maxLength: 253 }),
|
|
22
|
+
pathname: t.string({ minLength: 1, maxLength: 2048 }),
|
|
23
|
+
featureSummary: t.string({ minLength: 2, maxLength: 8192 }),
|
|
24
|
+
corpusEvidence: t.string({ minLength: 2, maxLength: 16_384 }),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const linkSafetyDecision = defineDecision({
|
|
28
|
+
name: "link.safety",
|
|
29
|
+
version: "1.0.0",
|
|
30
|
+
state: linkSafetyState,
|
|
31
|
+
questions: {
|
|
32
|
+
verdict: choice({
|
|
33
|
+
instructions:
|
|
34
|
+
"Classify the normalized URL as benign, suspicious, or malicious. Use suspicious when evidence is mixed or incomplete.",
|
|
35
|
+
criteria: {
|
|
36
|
+
benign: "No meaningful abuse signal is present.",
|
|
37
|
+
suspicious: "The URL needs review because signals are ambiguous or risky.",
|
|
38
|
+
malicious:
|
|
39
|
+
"The URL strongly indicates phishing, malware, or another abuse pattern.",
|
|
40
|
+
},
|
|
41
|
+
}),
|
|
42
|
+
risk: score({
|
|
43
|
+
instructions: "Score the operational risk of allowing this URL to proceed.",
|
|
44
|
+
criteria: ["none", "low", "moderate", "high", "critical"],
|
|
45
|
+
}),
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
export interface TypeSafeLinkClassifierOptions {
|
|
50
|
+
readonly apiKey?: string;
|
|
51
|
+
readonly model?: string;
|
|
52
|
+
readonly baseUrl?: string;
|
|
53
|
+
readonly timeoutMs?: number;
|
|
54
|
+
readonly maxResponseBytes?: number;
|
|
55
|
+
readonly minimumConfidence?: number;
|
|
56
|
+
readonly exampleLimit?: number;
|
|
57
|
+
readonly provider?: DecisionProvider;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function createTypeSafeLinkClassifier(
|
|
61
|
+
options: TypeSafeLinkClassifierOptions,
|
|
62
|
+
): LinkClassifier {
|
|
63
|
+
const minimumConfidence = options.minimumConfidence ?? DEFAULT_MINIMUM_CONFIDENCE;
|
|
64
|
+
if (
|
|
65
|
+
!Number.isFinite(minimumConfidence) ||
|
|
66
|
+
minimumConfidence < 0 ||
|
|
67
|
+
minimumConfidence > 1
|
|
68
|
+
) {
|
|
69
|
+
throw new RangeError("typesafe link classifier: minimumConfidence is invalid");
|
|
70
|
+
}
|
|
71
|
+
const exampleLimit = options.exampleLimit ?? MAX_EXAMPLES;
|
|
72
|
+
if (
|
|
73
|
+
!Number.isSafeInteger(exampleLimit) ||
|
|
74
|
+
exampleLimit < 0 ||
|
|
75
|
+
exampleLimit > MAX_EXAMPLES
|
|
76
|
+
) {
|
|
77
|
+
throw new RangeError("typesafe link classifier: exampleLimit is invalid");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const provider = options.provider ?? createProvider(options);
|
|
81
|
+
return Object.freeze({
|
|
82
|
+
id: "typesafe",
|
|
83
|
+
classify: async (input: LinkClassifierInput): Promise<LinkClassifierResult> => {
|
|
84
|
+
if (input.signal.aborted) return { ok: false, error: "cancelled" };
|
|
85
|
+
const state = createState(input, exampleLimit);
|
|
86
|
+
const result = await linkSafetyDecision.evaluate(state, {
|
|
87
|
+
provider,
|
|
88
|
+
signal: input.signal,
|
|
89
|
+
policy: {
|
|
90
|
+
allowAct: true,
|
|
91
|
+
minimumConfidence,
|
|
92
|
+
decide: (answers) =>
|
|
93
|
+
answers.verdict.choice === "suspicious" ? "review" : "act",
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
if (!result.ok) return { ok: false, error: mapDecisionError(result.error.code) };
|
|
97
|
+
const riskLevels = linkSafetyDecision.questions.risk.criteria;
|
|
98
|
+
const riskScore = result.answers.risk.score / Math.max(1, riskLevels.length - 1);
|
|
99
|
+
const reasons: readonly LinkSafetyReason[] = ["semantic_classifier"];
|
|
100
|
+
return {
|
|
101
|
+
ok: true,
|
|
102
|
+
verdict: result.answers.verdict.choice,
|
|
103
|
+
riskScore,
|
|
104
|
+
confidence: Math.min(
|
|
105
|
+
result.answers.verdict.confidence,
|
|
106
|
+
result.answers.risk.confidence,
|
|
107
|
+
),
|
|
108
|
+
reasons,
|
|
109
|
+
provider: result.metadata.provider,
|
|
110
|
+
...(result.metadata.model === undefined
|
|
111
|
+
? {}
|
|
112
|
+
: { model: result.metadata.model }),
|
|
113
|
+
};
|
|
114
|
+
},
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function createProvider(options: TypeSafeLinkClassifierOptions): DecisionProvider {
|
|
119
|
+
if (
|
|
120
|
+
typeof options.apiKey !== "string" ||
|
|
121
|
+
options.apiKey.trim() === "" ||
|
|
122
|
+
typeof options.model !== "string" ||
|
|
123
|
+
options.model.trim() === ""
|
|
124
|
+
) {
|
|
125
|
+
throw new TypeError(
|
|
126
|
+
"typesafe link classifier: apiKey and model are required without a provider",
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
const providerOptions: TypeSafeProviderOptions = {
|
|
130
|
+
apiKey: options.apiKey,
|
|
131
|
+
model: options.model,
|
|
132
|
+
...(options.baseUrl === undefined ? {} : { baseUrl: options.baseUrl }),
|
|
133
|
+
...(options.timeoutMs === undefined ? {} : { timeoutMs: options.timeoutMs }),
|
|
134
|
+
...(options.maxResponseBytes === undefined
|
|
135
|
+
? {}
|
|
136
|
+
: { maxResponseBytes: options.maxResponseBytes }),
|
|
137
|
+
};
|
|
138
|
+
return createTypeSafeProvider(providerOptions);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function createState(
|
|
142
|
+
input: LinkClassifierInput,
|
|
143
|
+
exampleLimit: number,
|
|
144
|
+
): {
|
|
145
|
+
readonly protocol: string;
|
|
146
|
+
readonly hostname: string;
|
|
147
|
+
readonly pathname: string;
|
|
148
|
+
readonly featureSummary: string;
|
|
149
|
+
readonly corpusEvidence: string;
|
|
150
|
+
} {
|
|
151
|
+
const featureSummary = JSON.stringify({
|
|
152
|
+
protocol: input.link.protocol,
|
|
153
|
+
hostname: input.link.hostname,
|
|
154
|
+
pathname: input.link.pathname,
|
|
155
|
+
flags: input.link.flags,
|
|
156
|
+
hasQuery: input.link.hasQuery,
|
|
157
|
+
queryParameterCount: input.link.queryParameterCount,
|
|
158
|
+
queryLength: input.link.queryLength,
|
|
159
|
+
});
|
|
160
|
+
const corpusEvidence = JSON.stringify({
|
|
161
|
+
matches: input.matches.map((match) => serializeEntry(match.entry)),
|
|
162
|
+
examples: input.examples.slice(0, exampleLimit).map(serializeEntry),
|
|
163
|
+
});
|
|
164
|
+
return {
|
|
165
|
+
protocol: input.link.protocol,
|
|
166
|
+
hostname: input.link.hostname,
|
|
167
|
+
pathname: input.link.pathname,
|
|
168
|
+
featureSummary,
|
|
169
|
+
corpusEvidence,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function serializeEntry(entry: CorpusEntry): Record<string, string> {
|
|
174
|
+
const safeIndicator =
|
|
175
|
+
entry.indicatorType === "url_prefix"
|
|
176
|
+
? normalizeUrlIndicator(entry.indicator)
|
|
177
|
+
: entry.indicator;
|
|
178
|
+
return {
|
|
179
|
+
id: entry.id,
|
|
180
|
+
indicatorType: entry.indicatorType,
|
|
181
|
+
indicator: safeIndicator,
|
|
182
|
+
label: entry.label,
|
|
183
|
+
...(entry.category === undefined ? {} : { category: entry.category }),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function normalizeUrlIndicator(value: string): string {
|
|
188
|
+
const normalized = normalizeLink(value);
|
|
189
|
+
return normalized.ok ? normalized.link.canonical : "invalid-indicator";
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function mapDecisionError(code: DecisionErrorCode): LinkSafetyErrorCode {
|
|
193
|
+
if (code === "cancelled") return "cancelled";
|
|
194
|
+
if (code === "provider_timeout") return "classifier_timeout";
|
|
195
|
+
if (code === "provider_rate_limited") return "classifier_rate_limited";
|
|
196
|
+
if (code === "provider_unauthorized") return "classifier_unauthorized";
|
|
197
|
+
if (code === "provider_bad_request") return "classifier_response_invalid";
|
|
198
|
+
if (code === "provider_response_invalid") return "classifier_response_invalid";
|
|
199
|
+
if (code === "provider_unavailable") return "classifier_unavailable";
|
|
200
|
+
return "classifier_response_invalid";
|
|
201
|
+
}
|