@slicedearth/whoisleuth-cli 2.5.1 → 2.6.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/DISCLOSURE +1 -1
- package/cli/artifact-validation/case-response.mjs +51 -8
- package/cli/artifact-validation/lookup-evidence.mjs +15 -1
- package/cli/artifact-validation/signed-review.mjs +2 -51
- package/cli/artifact-verify.mjs +9 -2
- package/cli/bulk-command-runner.mjs +4 -6
- package/cli/case-command.mjs +79 -3
- package/cli/case-pack.mjs +7 -2
- package/cli/command-definition.mjs +2 -1
- package/cli/discovery-scan-command-runner.mjs +4 -6
- package/cli/domain-feed.mjs +61 -0
- package/cli/formatters/json.mjs +1 -13
- package/cli/indicator-set-command.mjs +1 -2
- package/cli/intake-command.mjs +43 -3
- package/cli/investigation-artifacts.mjs +2 -3
- package/cli/investigation-package-review.mjs +2 -0
- package/cli/local-mmdb-review.mjs +27 -4
- package/cli/lookup-command-runner.mjs +1 -0
- package/cli/offline-evidence-review.mjs +51 -5
- package/cli/review-arguments.mjs +3 -2
- package/cli/review-command-definitions.mjs +14 -8
- package/cli/review-command-runner.mjs +6 -3
- package/cli/risk-calibration.mjs +6 -4
- package/cli/selected-input-worker.mjs +12 -2
- package/cli/support-command-runner.mjs +3 -3
- package/cli/watchlist-review.mjs +43 -0
- package/cli/workflow-arguments.mjs +14 -0
- package/cli/workflow-command-definitions.mjs +26 -1
- package/cli/workflow-command-runner.mjs +32 -0
- package/docs/cli.md +99 -2
- package/docs/contextual-reviews.md +28 -5
- package/docs/infrastructure-observations.md +126 -0
- package/docs/offline-intake-context.md +213 -0
- package/lib/credential-form-attribution.mjs +136 -0
- package/lib/credential-surface-profile.mjs +6 -2
- package/lib/dns-delegation-health.mjs +23 -7
- package/lib/dns-intelligence.mjs +18 -4
- package/lib/domain-posture-analysis.mjs +53 -2
- package/lib/domain-posture-parsers.mjs +9 -0
- package/lib/domain-posture.mjs +29 -10
- package/lib/evidence-export-privacy.mjs +5 -2
- package/lib/evidence-report.mjs +1 -0
- package/lib/favicon.mjs +13 -4
- package/lib/html-signals.mjs +6 -0
- package/lib/lookup-child-profile-contract.mjs +28 -11
- package/lib/lookup-response-contract.mjs +1 -0
- package/lib/operation-budget.mjs +4 -0
- package/lib/service-binding-dns.mjs +19 -2
- package/lib/sslbl-certificates.generated.mjs +138 -137
- package/lib/static-html-analysis.mjs +32 -4
- package/lib/technology-evidence-role.mjs +1 -51
- package/lib/tls-intelligence.mjs +30 -5
- package/lib/website-enrichment.mjs +39 -23
- package/lib/whois-chain.mjs +12 -3
- package/lib/whois-common-formats.mjs +3 -1
- package/lib/whois-contacts.mjs +13 -2
- package/package.json +7 -5
- package/packages/analysis/risk-scoring.mjs +12 -9
- package/packages/cases/case-containment-handoff.mjs +195 -0
- package/packages/cases/case-evidence-links.mjs +16 -0
- package/packages/cases/case-evidence-model.mjs +14 -3
- package/packages/cases/case-object-coverage.mjs +33 -0
- package/packages/cases/case-packet-correction.mjs +106 -0
- package/packages/cases/case-recheck-model.mjs +35 -5
- package/packages/cases/case-record-operations.mjs +74 -9
- package/packages/cases/case-record-projection.mjs +15 -3
- package/packages/cases/case-record-version-input.mjs +23 -1
- package/packages/cases/case-report.mjs +30 -0
- package/packages/cases/case-requested-evidence.mjs +1 -1
- package/packages/cases/case-response-actions.mjs +57 -1
- package/packages/cases/case-response-model.mjs +7 -0
- package/packages/cases/case-response-object.mjs +119 -0
- package/packages/cases/case-response-outcomes.mjs +81 -14
- package/packages/cases/case-response-packet-render.mjs +22 -3
- package/packages/cases/case-response-packet.mjs +30 -2
- package/packages/cases/case-response-review-inputs.mjs +48 -7
- package/packages/cases/case-workflow-metadata.mjs +1 -9
- package/packages/contracts/analyst-interchange.mjs +7 -5
- package/packages/contracts/candidate-watch-review.mjs +54 -0
- package/packages/contracts/case-portability.mjs +260 -42
- package/packages/contracts/cli-command-semantics.mjs +3 -0
- package/packages/contracts/context-review.mjs +2 -1
- package/packages/contracts/external-observation-interchange.mjs +96 -28
- package/packages/contracts/lookup-evidence.mjs +3 -2
- package/packages/contracts/message-intake.mjs +7 -1
- package/packages/contracts/risk-calibration.mjs +44 -29
- package/packages/contracts/schema-lifecycle.mjs +3 -1
- package/packages/contracts/web-capture.mjs +1 -1
- package/packages/contracts/workspace-portability.mjs +86 -16
- package/packages/evidence/collection-quality.mjs +1 -1
- package/packages/evidence/credential-form-attribution.mjs +60 -0
- package/packages/evidence/image-regions.mjs +15 -0
- package/packages/evidence/record-identity.mjs +60 -16
- package/packages/evidence/technology-evidence-role.mjs +51 -0
- package/packages/interchange/external-findings-import.mjs +32 -17
- package/packages/interchange/json-output.mjs +14 -0
- package/packages/interchange/managed-indicator-export.mjs +3 -2
- package/packages/interchange/managed-indicator-set.mjs +13 -0
- package/packages/investigation/acquisition-decision-packet.mjs +78 -0
- package/packages/investigation/context-review.mjs +3 -3
- package/packages/investigation/document-intake.mjs +9 -3
- package/packages/investigation/domain-history-review.mjs +43 -11
- package/packages/investigation/domain-transition-review.mjs +2 -0
- package/packages/investigation/infrastructure-observation.mjs +234 -0
- package/packages/investigation/intake-context.mjs +284 -0
- package/packages/investigation/intake-indicators.mjs +116 -0
- package/packages/investigation/intake-phones.mjs +155 -0
- package/packages/investigation/intake-report.mjs +2 -1
- package/packages/investigation/investigation-manifest.mjs +33 -7
- package/packages/investigation/link-intake.mjs +38 -8
- package/packages/investigation/message-intake.mjs +55 -15
- package/packages/monitoring/domain-feed.mjs +321 -0
- package/packages/workspace/brand-candidate-workflow.mjs +351 -0
- package/packages/workspace/brand-keyword-campaign.mjs +231 -0
- package/packages/workspace/brand-profile-model.mjs +46 -2
- package/packages/workspace/bulk-session-model.mjs +4 -0
- package/packages/workspace/candidate-watch-handoff.mjs +195 -0
- package/packages/workspace/detection-rule-model.mjs +10 -2
- package/packages/workspace/shortlist-model.mjs +10 -2
- package/packages/workspace/watchlist-history.mjs +31 -4
- package/packages/workspace/watchlist-store.mjs +136 -7
- package/third-party-notices.txt +29 -29
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import { normalizeDomain } from "../evidence/domain-name.mjs";
|
|
2
|
+
import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
|
|
3
|
+
import { sha256IdentityHex } from "../evidence/record-identity.mjs";
|
|
4
|
+
import { ordinaryWorkspaceRecord } from "./hostile-input.mjs";
|
|
5
|
+
import { MAX_CANDIDATE_OBSERVATIONS, MAX_CANDIDATE_MATCHES, MAX_CANDIDATE_SOURCES, MAX_CANDIDATE_TEXT, MAX_CANDIDATE_EXCEPTIONS, MAX_CANDIDATE_EXCEPTION_HISTORY, MAX_WATCH_CONTEXTS, MAX_WATCHLIST_DOMAINS, MAX_WATCHLIST_INPUT_RECORDS, } from "../contracts/workspace-portability.mjs";
|
|
6
|
+
export { MAX_CANDIDATE_OBSERVATIONS, MAX_CANDIDATE_MATCHES, MAX_CANDIDATE_SOURCES, MAX_CANDIDATE_TEXT, MAX_CANDIDATE_EXCEPTIONS, MAX_CANDIDATE_EXCEPTION_HISTORY, MAX_WATCH_CONTEXTS, } from "../contracts/workspace-portability.mjs";
|
|
7
|
+
/** Analyst urgency never changes a score, collection mode or schedule. */
|
|
8
|
+
export const WATCH_PRIORITIES = Object.freeze([
|
|
9
|
+
{ value: 'unassigned', label: 'Unassigned' },
|
|
10
|
+
{ value: 'p1', label: 'P1 immediate review' },
|
|
11
|
+
{ value: 'p2', label: 'P2 prompt review' },
|
|
12
|
+
{ value: 'p3', label: 'P3 routine review' },
|
|
13
|
+
{ value: 'p4', label: 'P4 background watch' },
|
|
14
|
+
]);
|
|
15
|
+
const CONTROL = /[\u0000-\u001f\u007f]/u;
|
|
16
|
+
const ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u;
|
|
17
|
+
function record(value) {
|
|
18
|
+
return ordinaryWorkspaceRecord(value, 'Candidate metadata') ?? {};
|
|
19
|
+
}
|
|
20
|
+
function text(value, maximum = MAX_CANDIDATE_TEXT) {
|
|
21
|
+
return typeof value === 'string' && !CONTROL.test(value) && value.length <= maximum * 4
|
|
22
|
+
? value.replace(/\s+/gu, ' ').trim().slice(0, maximum)
|
|
23
|
+
: '';
|
|
24
|
+
}
|
|
25
|
+
function reference(value) {
|
|
26
|
+
return typeof value === 'string' && ID.test(value) ? value : '';
|
|
27
|
+
}
|
|
28
|
+
function time(value) {
|
|
29
|
+
return normalizeExplicitIsoTimestamp(value);
|
|
30
|
+
}
|
|
31
|
+
export function normalizeCandidateObservation(value) {
|
|
32
|
+
const raw = record(value), domain = normalizeDomain(raw.domain);
|
|
33
|
+
if (!domain)
|
|
34
|
+
return null;
|
|
35
|
+
if ((Array.isArray(raw.matches) && raw.matches.length > MAX_CANDIDATE_MATCHES) ||
|
|
36
|
+
(Array.isArray(raw.sources) && raw.sources.length > MAX_CANDIDATE_SOURCES)) {
|
|
37
|
+
throw new RangeError('Candidate provenance exceeds its retained bound. Reduce the exact selection; no source was silently chosen or discarded.');
|
|
38
|
+
}
|
|
39
|
+
const matches = new Map();
|
|
40
|
+
for (const entry of (Array.isArray(raw.matches) ? raw.matches : []).slice(0, MAX_CANDIDATE_MATCHES * 4)) {
|
|
41
|
+
const match = record(entry), brandProfileId = reference(match.brandProfileId), ruleKey = reference(match.ruleKey);
|
|
42
|
+
const reason = text(match.reason), term = text(match.term);
|
|
43
|
+
if (brandProfileId && ruleKey && reason) {
|
|
44
|
+
const key = `${brandProfileId}\0${ruleKey}`, next = { brandProfileId, ruleKey, term, reason }, prior = matches.get(key);
|
|
45
|
+
if (prior && JSON.stringify(prior) !== JSON.stringify(next))
|
|
46
|
+
throw new TypeError('The same Brand rule has conflicting candidate reasons. Keep its source identity separate.');
|
|
47
|
+
matches.set(key, next);
|
|
48
|
+
}
|
|
49
|
+
if (matches.size >= MAX_CANDIDATE_MATCHES)
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
const sources = new Map();
|
|
53
|
+
for (const entry of (Array.isArray(raw.sources) ? raw.sources : []).slice(0, MAX_CANDIDATE_SOURCES * 4)) {
|
|
54
|
+
const input = record(entry), source = text(input.source, 100), observedHostname = normalizeDomain(input.observedHostname);
|
|
55
|
+
if (!source ||
|
|
56
|
+
!observedHostname ||
|
|
57
|
+
/https?:\/\/|[?]/iu.test(source) ||
|
|
58
|
+
/https?:\/\/|[?]/iu.test(String(input.revision ?? '')))
|
|
59
|
+
continue;
|
|
60
|
+
const first = time(input.sourceFirstObservedAt), last = time(input.sourceLastObservedAt);
|
|
61
|
+
const invalidInterval = Boolean(first && last && first > last);
|
|
62
|
+
const malformedClock = (input.sourceFirstObservedAt != null && !first) ||
|
|
63
|
+
(input.sourceLastObservedAt != null && !last);
|
|
64
|
+
const observation = {
|
|
65
|
+
source,
|
|
66
|
+
observedHostname,
|
|
67
|
+
revision: text(input.revision, 128) || null,
|
|
68
|
+
sourceFirstObservedAt: invalidInterval ? null : first,
|
|
69
|
+
sourceLastObservedAt: invalidInterval ? null : last,
|
|
70
|
+
firstLocalObservedAt: time(input.firstLocalObservedAt),
|
|
71
|
+
completeness: invalidInterval || malformedClock
|
|
72
|
+
? 'partial'
|
|
73
|
+
: input.completeness === 'complete' || input.completeness === 'partial'
|
|
74
|
+
? input.completeness
|
|
75
|
+
: 'unknown',
|
|
76
|
+
gap: invalidInterval
|
|
77
|
+
? 'The source-reported interval is inconsistent.'
|
|
78
|
+
: malformedClock
|
|
79
|
+
? 'A supplied source observation clock is invalid or lacks an explicit timezone.'
|
|
80
|
+
: text(input.gap),
|
|
81
|
+
};
|
|
82
|
+
sources.set(JSON.stringify(observation), observation);
|
|
83
|
+
if (sources.size >= MAX_CANDIDATE_SOURCES)
|
|
84
|
+
break;
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
domain,
|
|
88
|
+
matches: [...matches.values()].sort((a, b) => `${a.brandProfileId}:${a.ruleKey}`.localeCompare(`${b.brandProfileId}:${b.ruleKey}`)),
|
|
89
|
+
sources: [...sources.values()],
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
export function candidateMaterialFingerprint(candidate, brandProfileId, ruleKey) {
|
|
93
|
+
return `material:${sha256IdentityHex(new TextEncoder().encode(JSON.stringify({
|
|
94
|
+
domain: candidate.domain,
|
|
95
|
+
matches: candidate.matches.filter((match) => match.brandProfileId === brandProfileId && (!ruleKey || match.ruleKey === ruleKey)),
|
|
96
|
+
// Local retention time is not material evidence and repeated captures do not recur.
|
|
97
|
+
sources: candidate.sources
|
|
98
|
+
.map(({ firstLocalObservedAt: _local, ...source }) => source)
|
|
99
|
+
.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))),
|
|
100
|
+
})))}`;
|
|
101
|
+
}
|
|
102
|
+
export function mergeCandidateObservations(left, right) {
|
|
103
|
+
const a = normalizeCandidateObservation(left), b = normalizeCandidateObservation(right);
|
|
104
|
+
if (!a)
|
|
105
|
+
return b;
|
|
106
|
+
if (!b)
|
|
107
|
+
return a;
|
|
108
|
+
if (a.domain !== b.domain)
|
|
109
|
+
throw new TypeError('Candidate provenance can only merge for the exact same domain.');
|
|
110
|
+
const sources = new Map();
|
|
111
|
+
for (const source of [...a.sources, ...b.sources]) {
|
|
112
|
+
const { firstLocalObservedAt: _local, ...material } = source;
|
|
113
|
+
const key = JSON.stringify(material), old = sources.get(key);
|
|
114
|
+
sources.set(key, {
|
|
115
|
+
...source,
|
|
116
|
+
firstLocalObservedAt: old?.firstLocalObservedAt && source.firstLocalObservedAt
|
|
117
|
+
? old.firstLocalObservedAt < source.firstLocalObservedAt
|
|
118
|
+
? old.firstLocalObservedAt
|
|
119
|
+
: source.firstLocalObservedAt
|
|
120
|
+
: (old?.firstLocalObservedAt ?? source.firstLocalObservedAt),
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
const matches = new Map();
|
|
124
|
+
for (const match of [...a.matches, ...b.matches]) {
|
|
125
|
+
const key = `${match.brandProfileId}\0${match.ruleKey}`, old = matches.get(key);
|
|
126
|
+
if (old && JSON.stringify(old) !== JSON.stringify(match))
|
|
127
|
+
throw new TypeError('The same Brand rule has conflicting candidate reasons. No observation was overwritten.');
|
|
128
|
+
matches.set(key, match);
|
|
129
|
+
}
|
|
130
|
+
return normalizeCandidateObservation({
|
|
131
|
+
domain: a.domain,
|
|
132
|
+
matches: [...matches.values()],
|
|
133
|
+
sources: [...sources.values()],
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
export function normalizeCandidateObservations(value) {
|
|
137
|
+
const output = new Map();
|
|
138
|
+
for (const raw of (Array.isArray(value) ? value : []).slice(0, MAX_CANDIDATE_OBSERVATIONS * 4)) {
|
|
139
|
+
const candidate = normalizeCandidateObservation(raw);
|
|
140
|
+
if (!candidate)
|
|
141
|
+
continue;
|
|
142
|
+
output.set(candidate.domain, mergeCandidateObservations(output.get(candidate.domain), candidate));
|
|
143
|
+
if (output.size >= MAX_CANDIDATE_OBSERVATIONS)
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
return [...output.values()];
|
|
147
|
+
}
|
|
148
|
+
export function normalizeWatchDomainMetadata(value, legacyDomains = []) {
|
|
149
|
+
const output = new Map();
|
|
150
|
+
for (const raw of (Array.isArray(value) ? value : []).slice(0, MAX_WATCHLIST_INPUT_RECORDS)) {
|
|
151
|
+
const entry = record(raw), domain = normalizeDomain(entry.domain);
|
|
152
|
+
if (!domain)
|
|
153
|
+
continue;
|
|
154
|
+
const prior = output.get(domain);
|
|
155
|
+
const contexts = new Map(prior?.contexts.map((context) => [context.brandProfileId ?? '', context]) ?? []);
|
|
156
|
+
for (const item of (Array.isArray(entry.contexts) ? entry.contexts : []).slice(0, MAX_WATCH_CONTEXTS * 4)) {
|
|
157
|
+
const input = record(item), brandProfileId = reference(input.brandProfileId) || null;
|
|
158
|
+
if (input.brandProfileId !== null && !brandProfileId)
|
|
159
|
+
continue;
|
|
160
|
+
const priority = WATCH_PRIORITIES.some((option) => option.value === input.priority)
|
|
161
|
+
? input.priority
|
|
162
|
+
: 'unassigned';
|
|
163
|
+
const context = {
|
|
164
|
+
brandProfileId,
|
|
165
|
+
priority,
|
|
166
|
+
reason: text(input.reason),
|
|
167
|
+
changedAt: time(input.changedAt),
|
|
168
|
+
reviewDueAt: time(input.reviewDueAt),
|
|
169
|
+
};
|
|
170
|
+
const key = brandProfileId ?? '', old = contexts.get(key);
|
|
171
|
+
if (!old || (context.changedAt && (!old.changedAt || context.changedAt > old.changedAt)))
|
|
172
|
+
contexts.set(key, context);
|
|
173
|
+
if (contexts.size >= MAX_WATCH_CONTEXTS)
|
|
174
|
+
break;
|
|
175
|
+
}
|
|
176
|
+
const candidate = normalizeCandidateObservation(entry.candidate);
|
|
177
|
+
output.set(domain, {
|
|
178
|
+
domain,
|
|
179
|
+
contexts: [...contexts.values()],
|
|
180
|
+
candidate: mergeCandidateObservations(prior?.candidate, candidate?.domain === domain ? candidate : null),
|
|
181
|
+
});
|
|
182
|
+
if (output.size > MAX_WATCHLIST_DOMAINS)
|
|
183
|
+
throw new RangeError(`Watchlists are limited to ${MAX_WATCHLIST_DOMAINS} active domains. No members were discarded.`);
|
|
184
|
+
}
|
|
185
|
+
for (const raw of legacyDomains.slice(0, MAX_WATCHLIST_INPUT_RECORDS)) {
|
|
186
|
+
const domain = normalizeDomain(raw);
|
|
187
|
+
if (domain && !output.has(domain))
|
|
188
|
+
output.set(domain, {
|
|
189
|
+
domain,
|
|
190
|
+
contexts: [
|
|
191
|
+
{
|
|
192
|
+
brandProfileId: null,
|
|
193
|
+
priority: 'unassigned',
|
|
194
|
+
reason: '',
|
|
195
|
+
changedAt: null,
|
|
196
|
+
reviewDueAt: null,
|
|
197
|
+
},
|
|
198
|
+
],
|
|
199
|
+
candidate: null,
|
|
200
|
+
});
|
|
201
|
+
if (output.size > MAX_WATCHLIST_DOMAINS)
|
|
202
|
+
throw new RangeError(`Watchlists are limited to ${MAX_WATCHLIST_DOMAINS} active domains. No members were discarded.`);
|
|
203
|
+
}
|
|
204
|
+
return [...output.values()];
|
|
205
|
+
}
|
|
206
|
+
export function mergeWatchDomainMetadata(local, imported) {
|
|
207
|
+
const output = new Map(normalizeWatchDomainMetadata(local).map((entry) => [entry.domain, entry]));
|
|
208
|
+
for (const incoming of normalizeWatchDomainMetadata(imported)) {
|
|
209
|
+
const previous = output.get(incoming.domain);
|
|
210
|
+
if (!previous) {
|
|
211
|
+
if (output.size >= MAX_WATCHLIST_DOMAINS)
|
|
212
|
+
throw new RangeError(`Watchlists are limited to ${MAX_WATCHLIST_DOMAINS} active domains. No members were discarded.`);
|
|
213
|
+
output.set(incoming.domain, incoming);
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
const contexts = new Map(previous.contexts.map((context) => [context.brandProfileId, context]));
|
|
217
|
+
for (const context of incoming.contexts) {
|
|
218
|
+
const old = contexts.get(context.brandProfileId);
|
|
219
|
+
if ((!old && contexts.size < MAX_WATCH_CONTEXTS) ||
|
|
220
|
+
(old && context.changedAt && (!old.changedAt || context.changedAt > old.changedAt)))
|
|
221
|
+
contexts.set(context.brandProfileId, context);
|
|
222
|
+
}
|
|
223
|
+
output.set(incoming.domain, {
|
|
224
|
+
domain: incoming.domain,
|
|
225
|
+
contexts: [...contexts.values()],
|
|
226
|
+
candidate: mergeCandidateObservations(previous.candidate, incoming.candidate),
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
return [...output.values()];
|
|
230
|
+
}
|
|
231
|
+
function exceptionSnapshot(value) {
|
|
232
|
+
const raw = record(value), reviewedAt = time(raw.reviewedAt), expiresAt = time(raw.expiresAt), reason = text(raw.reason);
|
|
233
|
+
if (!reviewedAt ||
|
|
234
|
+
!expiresAt ||
|
|
235
|
+
(raw.enabled === true && expiresAt <= reviewedAt) ||
|
|
236
|
+
!reason ||
|
|
237
|
+
!Number.isSafeInteger(raw.revision) ||
|
|
238
|
+
Number(raw.revision) < 1 ||
|
|
239
|
+
typeof raw.reviewedFingerprint !== 'string' ||
|
|
240
|
+
!/^material:[a-f0-9]{64}$/u.test(raw.reviewedFingerprint) ||
|
|
241
|
+
typeof raw.enabled !== 'boolean')
|
|
242
|
+
return null;
|
|
243
|
+
return {
|
|
244
|
+
revision: Number(raw.revision),
|
|
245
|
+
reviewedAt,
|
|
246
|
+
expiresAt,
|
|
247
|
+
reason,
|
|
248
|
+
reviewedFingerprint: raw.reviewedFingerprint,
|
|
249
|
+
enabled: raw.enabled,
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
export function normalizeCandidateExceptions(value) {
|
|
253
|
+
const output = new Map();
|
|
254
|
+
const seenRevisions = new Map();
|
|
255
|
+
const seenScopes = new Map();
|
|
256
|
+
for (const item of (Array.isArray(value) ? value : []).slice(0, MAX_CANDIDATE_EXCEPTIONS * 4)) {
|
|
257
|
+
const raw = record(item), snapshot = exceptionSnapshot(raw), id = reference(raw.id), domain = normalizeDomain(raw.domain), ruleKey = reference(raw.ruleKey);
|
|
258
|
+
if (!snapshot ||
|
|
259
|
+
!id ||
|
|
260
|
+
!domain ||
|
|
261
|
+
!ruleKey ||
|
|
262
|
+
!['irrelevant_match', 'deferred_review', 'accepted_temporary_change'].includes(String(raw.purpose)))
|
|
263
|
+
continue;
|
|
264
|
+
const history = (Array.isArray(raw.history) ? raw.history : [])
|
|
265
|
+
.slice(0, MAX_CANDIDATE_EXCEPTION_HISTORY * 4)
|
|
266
|
+
.map(exceptionSnapshot)
|
|
267
|
+
.filter((entry) => entry !== null)
|
|
268
|
+
.slice(0, MAX_CANDIDATE_EXCEPTION_HISTORY);
|
|
269
|
+
const exception = {
|
|
270
|
+
...snapshot,
|
|
271
|
+
id,
|
|
272
|
+
domain,
|
|
273
|
+
ruleKey,
|
|
274
|
+
purpose: raw.purpose,
|
|
275
|
+
history,
|
|
276
|
+
historyOmitted: Number.isSafeInteger(raw.historyOmitted) && Number(raw.historyOmitted) >= 0
|
|
277
|
+
? Number(raw.historyOmitted)
|
|
278
|
+
: 0,
|
|
279
|
+
};
|
|
280
|
+
const old = output.get(id);
|
|
281
|
+
const scope = JSON.stringify([domain, ruleKey, exception.purpose]);
|
|
282
|
+
const previousScope = seenScopes.get(id);
|
|
283
|
+
if (previousScope !== undefined && previousScope !== scope)
|
|
284
|
+
throw new TypeError('An exception identifier cannot change its exact domain, rule or purpose.');
|
|
285
|
+
seenScopes.set(id, scope);
|
|
286
|
+
const revisionKey = JSON.stringify([id, exception.revision]);
|
|
287
|
+
const revisionValue = JSON.stringify(exception);
|
|
288
|
+
const previousRevision = seenRevisions.get(revisionKey);
|
|
289
|
+
if (previousRevision !== undefined && previousRevision !== revisionValue)
|
|
290
|
+
throw new TypeError('The same exception identifier and revision contain conflicting decisions. No exception was imported.');
|
|
291
|
+
seenRevisions.set(revisionKey, revisionValue);
|
|
292
|
+
if ((old && exception.revision > old.revision) || (!old && output.size < MAX_CANDIDATE_EXCEPTIONS))
|
|
293
|
+
output.set(id, exception);
|
|
294
|
+
}
|
|
295
|
+
return [...output.values()];
|
|
296
|
+
}
|
|
297
|
+
export function candidateExceptionState(exception, candidate, brandProfileId, now) {
|
|
298
|
+
if (!exception.enabled)
|
|
299
|
+
return 'disabled';
|
|
300
|
+
if (exception.domain !== candidate.domain ||
|
|
301
|
+
!candidate.matches.some((match) => match.brandProfileId === brandProfileId && match.ruleKey === exception.ruleKey))
|
|
302
|
+
return 'unmatched';
|
|
303
|
+
const clock = time(now);
|
|
304
|
+
if (!clock || clock < exception.reviewedAt)
|
|
305
|
+
return 'clock_unavailable';
|
|
306
|
+
if (clock >= exception.expiresAt)
|
|
307
|
+
return 'expired';
|
|
308
|
+
if (candidateMaterialFingerprint(candidate, brandProfileId, exception.ruleKey) !==
|
|
309
|
+
exception.reviewedFingerprint)
|
|
310
|
+
return 'changed';
|
|
311
|
+
return 'active';
|
|
312
|
+
}
|
|
313
|
+
/** Updates are revision-checked; disabling preserves the previous reason and snapshot. */
|
|
314
|
+
export function reviseCandidateException(existing, input, expectedRevision) {
|
|
315
|
+
if ((existing?.revision ?? null) !== expectedRevision)
|
|
316
|
+
throw new Error('The candidate exception changed. Reopen it before saving; nothing was overwritten.');
|
|
317
|
+
if (typeof input.reason !== 'string' ||
|
|
318
|
+
input.reason.length > MAX_CANDIDATE_TEXT ||
|
|
319
|
+
CONTROL.test(input.reason))
|
|
320
|
+
throw new TypeError('Exception reasons must contain 1–300 plain-text characters.');
|
|
321
|
+
const revision = (existing?.revision ?? 0) + 1;
|
|
322
|
+
const retainedReviewClock = existing
|
|
323
|
+
? [existing.reviewedAt, ...existing.history.map(snapshot => snapshot.reviewedAt)].sort().at(-1)
|
|
324
|
+
: null;
|
|
325
|
+
const submittedReviewClock = time(input.reviewedAt);
|
|
326
|
+
if (input.enabled && retainedReviewClock && submittedReviewClock && submittedReviewClock < retainedReviewClock)
|
|
327
|
+
throw new TypeError('The review clock precedes a retained exception review. Correct the clock before renewing or re-enabling it.');
|
|
328
|
+
const reviewedAt = !input.enabled && retainedReviewClock && submittedReviewClock && submittedReviewClock < retainedReviewClock
|
|
329
|
+
? retainedReviewClock
|
|
330
|
+
: input.reviewedAt;
|
|
331
|
+
const prior = existing ? [exceptionSnapshot(existing), ...existing.history] : [];
|
|
332
|
+
const normalized = normalizeCandidateExceptions([
|
|
333
|
+
{
|
|
334
|
+
...input,
|
|
335
|
+
reviewedAt,
|
|
336
|
+
revision,
|
|
337
|
+
history: prior.slice(0, MAX_CANDIDATE_EXCEPTION_HISTORY),
|
|
338
|
+
historyOmitted: (existing?.historyOmitted ?? 0) +
|
|
339
|
+
Math.max(0, prior.length - MAX_CANDIDATE_EXCEPTION_HISTORY),
|
|
340
|
+
},
|
|
341
|
+
])[0];
|
|
342
|
+
if (!normalized)
|
|
343
|
+
throw new TypeError('Use an exact domain and rule, a reason, a future expiry and current reviewed evidence.');
|
|
344
|
+
if (existing &&
|
|
345
|
+
(input.id !== existing.id ||
|
|
346
|
+
input.domain !== existing.domain ||
|
|
347
|
+
input.ruleKey !== existing.ruleKey ||
|
|
348
|
+
input.purpose !== existing.purpose))
|
|
349
|
+
throw new TypeError('An exception revision cannot widen its domain or rule scope. Create and preview another exact exception.');
|
|
350
|
+
return normalized;
|
|
351
|
+
}
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
import { normalizeExplicitIsoTimestamp } from "../evidence/observation.mjs";
|
|
2
|
+
import { sha256IdentityHex } from "../evidence/record-identity.mjs";
|
|
3
|
+
import { ordinaryWorkspaceRecord, assertWorkspaceInputGraph } from "./hostile-input.mjs";
|
|
4
|
+
import { WATCH_PRIORITIES, normalizeCandidateObservation, } from "./brand-candidate-workflow.mjs";
|
|
5
|
+
import { MAX_BRAND_KEYWORD_CAMPAIGNS, MAX_BRAND_KEYWORD_CAMPAIGN_HISTORY, MAX_BRAND_KEYWORD_TERMS, MAX_BRAND_KEYWORD_TERM_LENGTH, MAX_PROFILE_NAME_LENGTH, } from "../contracts/workspace-portability.mjs";
|
|
6
|
+
export { MAX_BRAND_KEYWORD_CAMPAIGNS, MAX_BRAND_KEYWORD_CAMPAIGN_HISTORY, MAX_BRAND_KEYWORD_TERMS, MAX_BRAND_KEYWORD_TERM_LENGTH, } from "../contracts/workspace-portability.mjs";
|
|
7
|
+
const CONTROL = /[\u0000-\u001f\u007f-\u009f\u202a-\u202e\u2066-\u2069]/u;
|
|
8
|
+
const ID = /^[A-Za-z0-9_-]{1,64}$/u;
|
|
9
|
+
/** Shared feed and campaign literals are plain substrings, never regular expressions. */
|
|
10
|
+
export function normalizeBrandKeywordTerms(value) {
|
|
11
|
+
if (!Array.isArray(value) || value.length > MAX_BRAND_KEYWORD_TERMS)
|
|
12
|
+
throw new RangeError('Use at most 20 literal terms in each selection.');
|
|
13
|
+
assertWorkspaceInputGraph(value, 'Keyword literals');
|
|
14
|
+
return [
|
|
15
|
+
...new Set(value.map((term) => {
|
|
16
|
+
if (typeof term !== 'string' ||
|
|
17
|
+
term.length < 3 ||
|
|
18
|
+
term.length > MAX_BRAND_KEYWORD_TERM_LENGTH ||
|
|
19
|
+
term !== term.trim() ||
|
|
20
|
+
CONTROL.test(term))
|
|
21
|
+
throw new TypeError('Literal feed terms must contain 3–80 characters without controls or surrounding spaces.');
|
|
22
|
+
const literal = term.toLowerCase();
|
|
23
|
+
if (literal.length > MAX_BRAND_KEYWORD_TERM_LENGTH)
|
|
24
|
+
throw new TypeError('Lowercased literal terms must remain within 80 characters.');
|
|
25
|
+
return literal;
|
|
26
|
+
})),
|
|
27
|
+
];
|
|
28
|
+
}
|
|
29
|
+
/** The caller supplies a canonical hostname and already bounded literals. */
|
|
30
|
+
export function matchBrandKeywordTerms(hostname, positive, negative = []) {
|
|
31
|
+
const terms = positive.filter((term) => hostname.includes(term));
|
|
32
|
+
const excludedTerms = negative.filter((term) => hostname.includes(term));
|
|
33
|
+
return { terms, excludedTerms, matched: terms.length > 0 && excludedTerms.length === 0 };
|
|
34
|
+
}
|
|
35
|
+
function snapshot(value) {
|
|
36
|
+
const raw = ordinaryWorkspaceRecord(value, 'Keyword campaign revision');
|
|
37
|
+
if (!raw)
|
|
38
|
+
throw new TypeError('A keyword campaign revision must be a record.');
|
|
39
|
+
const startsAt = normalizeExplicitIsoTimestamp(raw.startsAt), endsAt = normalizeExplicitIsoTimestamp(raw.endsAt), changedAt = normalizeExplicitIsoTimestamp(raw.changedAt);
|
|
40
|
+
if (!startsAt || !endsAt || !changedAt || Date.parse(endsAt) <= Date.parse(startsAt))
|
|
41
|
+
throw new TypeError('Use explicit campaign start and end timestamps, with the end after the start.');
|
|
42
|
+
if (typeof raw.name !== 'string' ||
|
|
43
|
+
!raw.name.trim() ||
|
|
44
|
+
raw.name.length > MAX_PROFILE_NAME_LENGTH ||
|
|
45
|
+
CONTROL.test(raw.name))
|
|
46
|
+
throw new TypeError('Campaign names require 1–100 plain-text characters.');
|
|
47
|
+
if (!Number.isSafeInteger(raw.revision) ||
|
|
48
|
+
Number(raw.revision) < 1 ||
|
|
49
|
+
typeof raw.paused !== 'boolean' ||
|
|
50
|
+
!WATCH_PRIORITIES.some((option) => option.value === raw.defaultPriority))
|
|
51
|
+
throw new TypeError('The campaign revision, pause state or default priority is invalid.');
|
|
52
|
+
const positiveTerms = [...normalizeBrandKeywordTerms(raw.positiveTerms)].sort(), negativeTerms = [...normalizeBrandKeywordTerms(raw.negativeTerms)].sort();
|
|
53
|
+
if (!positiveTerms.length)
|
|
54
|
+
throw new TypeError('A keyword campaign requires at least one positive literal term.');
|
|
55
|
+
if (positiveTerms.some((term) => negativeTerms.includes(term)))
|
|
56
|
+
throw new TypeError('The same literal cannot be both positive and negative in one campaign.');
|
|
57
|
+
return {
|
|
58
|
+
revision: Number(raw.revision),
|
|
59
|
+
name: raw.name.trim(),
|
|
60
|
+
positiveTerms,
|
|
61
|
+
negativeTerms,
|
|
62
|
+
startsAt,
|
|
63
|
+
endsAt,
|
|
64
|
+
paused: raw.paused,
|
|
65
|
+
defaultPriority: raw.defaultPriority,
|
|
66
|
+
changedAt,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
function campaign(value) {
|
|
70
|
+
const raw = ordinaryWorkspaceRecord(value, 'Keyword campaign');
|
|
71
|
+
if (!raw || typeof raw.id !== 'string' || !ID.test(raw.id))
|
|
72
|
+
throw new TypeError('The keyword campaign identifier is invalid.');
|
|
73
|
+
const current = snapshot(raw), createdAt = normalizeExplicitIsoTimestamp(raw.createdAt);
|
|
74
|
+
if (!createdAt || Date.parse(createdAt) > Date.parse(current.changedAt))
|
|
75
|
+
throw new TypeError('The campaign creation clock is invalid.');
|
|
76
|
+
if (!Array.isArray(raw.history) || raw.history.length > MAX_BRAND_KEYWORD_CAMPAIGN_HISTORY)
|
|
77
|
+
throw new RangeError('Retain at most eight previous campaign revisions.');
|
|
78
|
+
const history = raw.history.map(snapshot).sort((a, b) => b.revision - a.revision);
|
|
79
|
+
const seen = new Set();
|
|
80
|
+
let later = current;
|
|
81
|
+
for (const item of history) {
|
|
82
|
+
if (seen.has(item.revision) ||
|
|
83
|
+
item.revision >= later.revision ||
|
|
84
|
+
Date.parse(item.changedAt) > Date.parse(later.changedAt) ||
|
|
85
|
+
Date.parse(item.changedAt) < Date.parse(createdAt))
|
|
86
|
+
throw new TypeError('Campaign revision history is repeated, out of order or has conflicting clocks.');
|
|
87
|
+
seen.add(item.revision);
|
|
88
|
+
later = item;
|
|
89
|
+
}
|
|
90
|
+
if (raw.historyOmitted !== current.revision - 1 - history.length)
|
|
91
|
+
throw new TypeError('Campaign revision omissions must account for every earlier revision.');
|
|
92
|
+
return { ...current, id: raw.id, createdAt, history, historyOmitted: Number(raw.historyOmitted) };
|
|
93
|
+
}
|
|
94
|
+
/** Equal revisions must agree even when a newer revision would otherwise win. */
|
|
95
|
+
export function mergeBrandKeywordCampaigns(left, right) {
|
|
96
|
+
const output = new Map();
|
|
97
|
+
for (const input of [left, right]) {
|
|
98
|
+
if (input === undefined)
|
|
99
|
+
continue;
|
|
100
|
+
if (!Array.isArray(input) || input.length > MAX_BRAND_KEYWORD_CAMPAIGNS)
|
|
101
|
+
throw new RangeError('Each Brand supports at most 20 keyword campaigns.');
|
|
102
|
+
assertWorkspaceInputGraph(input, 'Brand keyword campaigns');
|
|
103
|
+
for (const raw of input) {
|
|
104
|
+
const incoming = campaign(raw), existing = output.get(incoming.id);
|
|
105
|
+
if (!existing) {
|
|
106
|
+
output.set(incoming.id, incoming);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (incoming.createdAt !== existing.createdAt)
|
|
110
|
+
throw new TypeError('The same campaign identifier has conflicting creation identity.');
|
|
111
|
+
const revisions = new Map();
|
|
112
|
+
for (const item of [
|
|
113
|
+
snapshot(existing),
|
|
114
|
+
...existing.history,
|
|
115
|
+
snapshot(incoming),
|
|
116
|
+
...incoming.history,
|
|
117
|
+
]) {
|
|
118
|
+
const previous = revisions.get(item.revision);
|
|
119
|
+
if (previous && JSON.stringify(previous) !== JSON.stringify(item))
|
|
120
|
+
throw new TypeError('The same campaign revision contains conflicting intent. No campaign was imported.');
|
|
121
|
+
revisions.set(item.revision, item);
|
|
122
|
+
}
|
|
123
|
+
const [current, ...earlier] = [...revisions.values()].sort((a, b) => b.revision - a.revision);
|
|
124
|
+
const history = earlier.slice(0, MAX_BRAND_KEYWORD_CAMPAIGN_HISTORY);
|
|
125
|
+
output.set(incoming.id, campaign({
|
|
126
|
+
...current,
|
|
127
|
+
id: incoming.id,
|
|
128
|
+
createdAt: incoming.createdAt,
|
|
129
|
+
history,
|
|
130
|
+
historyOmitted: current.revision - 1 - history.length,
|
|
131
|
+
}));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
if (output.size > MAX_BRAND_KEYWORD_CAMPAIGNS)
|
|
135
|
+
throw new RangeError('The merged Brand exceeds its 20-campaign limit; no campaign was discarded.');
|
|
136
|
+
return [...output.values()].sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
137
|
+
}
|
|
138
|
+
export function normalizeBrandKeywordCampaigns(value) {
|
|
139
|
+
return mergeBrandKeywordCampaigns(undefined, value);
|
|
140
|
+
}
|
|
141
|
+
export function reviseBrandKeywordCampaign(existing, input, expectedRevision, nowRaw) {
|
|
142
|
+
assertWorkspaceInputGraph(input, 'Keyword campaign edit');
|
|
143
|
+
if ((existing?.revision ?? null) !== expectedRevision || (existing && input.id !== existing.id))
|
|
144
|
+
throw new Error('The campaign changed. Reopen its saved revision before editing; nothing was overwritten.');
|
|
145
|
+
const now = normalizeExplicitIsoTimestamp(nowRaw);
|
|
146
|
+
if (!now || (existing && Date.parse(now) < Date.parse(existing.changedAt)))
|
|
147
|
+
throw new TypeError('The campaign change clock precedes retained intent. Correct the clock before saving.');
|
|
148
|
+
if (existing && existing.revision >= Number.MAX_SAFE_INTEGER)
|
|
149
|
+
throw new RangeError('The campaign revision limit is exhausted.');
|
|
150
|
+
const history = existing
|
|
151
|
+
? [snapshot(existing), ...existing.history].slice(0, MAX_BRAND_KEYWORD_CAMPAIGN_HISTORY)
|
|
152
|
+
: [];
|
|
153
|
+
const revision = (existing?.revision ?? 0) + 1;
|
|
154
|
+
return campaign({
|
|
155
|
+
...input,
|
|
156
|
+
revision,
|
|
157
|
+
changedAt: now,
|
|
158
|
+
createdAt: existing?.createdAt ?? now,
|
|
159
|
+
history,
|
|
160
|
+
historyOmitted: revision - 1 - history.length,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
export function brandKeywordCampaignState(value, nowRaw) {
|
|
164
|
+
const now = normalizeExplicitIsoTimestamp(nowRaw);
|
|
165
|
+
if (!now || Date.parse(now) < Date.parse(value.changedAt))
|
|
166
|
+
return 'clock_unavailable';
|
|
167
|
+
if (value.paused)
|
|
168
|
+
return 'paused';
|
|
169
|
+
if (Date.parse(now) < Date.parse(value.startsAt))
|
|
170
|
+
return 'scheduled';
|
|
171
|
+
return Date.parse(now) >= Date.parse(value.endsAt) ? 'expired' : 'active';
|
|
172
|
+
}
|
|
173
|
+
export function keywordCampaignFeedSelection(value, brandProfileId, now) {
|
|
174
|
+
if (brandKeywordCampaignState(value, now) !== 'active')
|
|
175
|
+
throw new Error('Choose an active campaign revision. Scheduled, paused and expired campaigns do not nominate new candidates.');
|
|
176
|
+
return {
|
|
177
|
+
hosts: [],
|
|
178
|
+
terms: [...value.positiveTerms],
|
|
179
|
+
...(value.negativeTerms.length ? { negativeTerms: [...value.negativeTerms] } : {}),
|
|
180
|
+
brandProfileId,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
/** Keep source facts unchanged; add only the exact reviewed campaign revision. */
|
|
184
|
+
export function attributeKeywordCampaignCandidate(candidate, value, brandProfileId, now) {
|
|
185
|
+
keywordCampaignFeedSelection(value, brandProfileId, now);
|
|
186
|
+
const { terms, matched } = matchBrandKeywordTerms(candidate.domain, value.positiveTerms, value.negativeTerms);
|
|
187
|
+
if (!matched)
|
|
188
|
+
throw new Error('This candidate does not qualify for the selected campaign revision.');
|
|
189
|
+
const matches = terms.map((term) => ({
|
|
190
|
+
brandProfileId,
|
|
191
|
+
ruleKey: `keyword:${value.id}:${value.revision}:${sha256IdentityHex(new TextEncoder().encode(term)).slice(0, 24)}`,
|
|
192
|
+
term,
|
|
193
|
+
reason: `Literal match for keyword campaign “${value.name}”, revision ${value.revision}. Candidate nomination only; no collection or infringement finding.`,
|
|
194
|
+
}));
|
|
195
|
+
const result = normalizeCandidateObservation({ ...candidate, matches });
|
|
196
|
+
if (!result)
|
|
197
|
+
throw new Error('The campaign candidate attribution is invalid.');
|
|
198
|
+
return result;
|
|
199
|
+
}
|
|
200
|
+
export function keywordCampaignMatchContext(ruleKey, campaigns) {
|
|
201
|
+
const match = /^keyword:([A-Za-z0-9_-]{1,64}):(\d+):([a-f0-9]{24})$/u.exec(ruleKey);
|
|
202
|
+
if (!match)
|
|
203
|
+
return null;
|
|
204
|
+
const owner = campaigns.find((item) => item.id === match[1]);
|
|
205
|
+
const revision = owner && [owner, ...owner.history].find((item) => item.revision === Number(match[2]));
|
|
206
|
+
return owner &&
|
|
207
|
+
revision &&
|
|
208
|
+
revision.positiveTerms.some((term) => sha256IdentityHex(new TextEncoder().encode(term)).slice(0, 24) === match[3])
|
|
209
|
+
? { id: owner.id, revision }
|
|
210
|
+
: null;
|
|
211
|
+
}
|
|
212
|
+
/** Only one unambiguous retained default may prefill an explicit handoff. */
|
|
213
|
+
export function keywordCampaignDefaultPriority(candidates, brandProfileId, campaigns) {
|
|
214
|
+
if (!candidates.length)
|
|
215
|
+
return null;
|
|
216
|
+
const priorities = new Set();
|
|
217
|
+
for (const candidate of candidates) {
|
|
218
|
+
const matches = candidate.matches.filter((match) => match.brandProfileId === brandProfileId);
|
|
219
|
+
if (!matches.length)
|
|
220
|
+
return null;
|
|
221
|
+
for (const match of matches) {
|
|
222
|
+
const context = keywordCampaignMatchContext(match.ruleKey, campaigns);
|
|
223
|
+
if (!context ||
|
|
224
|
+
!context.revision.positiveTerms.includes(match.term) ||
|
|
225
|
+
!candidate.domain.includes(match.term))
|
|
226
|
+
return null;
|
|
227
|
+
priorities.add(context.revision.defaultPriority);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return priorities.size === 1 ? [...priorities][0] : null;
|
|
231
|
+
}
|