fullstackgtm 0.25.1 → 0.26.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/CHANGELOG.md +97 -0
- package/dist/bulkUpdate.js +6 -1
- package/dist/cli.js +67 -2
- package/dist/connector.js +90 -1
- package/dist/connectors/hubspot.js +5 -2
- package/dist/connectors/salesforce.js +4 -2
- package/dist/connectors/stripe.js +4 -2
- package/dist/credentials.js +22 -1
- package/dist/dedupe.d.ts +6 -0
- package/dist/dedupe.js +24 -1
- package/dist/enrich.js +24 -2
- package/dist/enrichApollo.js +5 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/integrity.d.ts +30 -0
- package/dist/integrity.js +128 -0
- package/dist/market.d.ts +1 -0
- package/dist/market.js +144 -8
- package/dist/marketReport.d.ts +9 -0
- package/dist/marketReport.js +29 -4
- package/dist/marketTaxonomy.d.ts +41 -0
- package/dist/marketTaxonomy.js +193 -0
- package/dist/planStore.d.ts +6 -0
- package/dist/planStore.js +10 -2
- package/dist/schedule.d.ts +17 -0
- package/dist/schedule.js +87 -2
- package/dist/types.d.ts +16 -0
- package/package.json +1 -1
- package/src/bulkUpdate.ts +6 -1
- package/src/cli.ts +80 -1
- package/src/connector.ts +96 -1
- package/src/connectors/hubspot.ts +5 -2
- package/src/connectors/salesforce.ts +4 -2
- package/src/connectors/stripe.ts +4 -2
- package/src/credentials.ts +24 -0
- package/src/dedupe.ts +23 -1
- package/src/enrich.ts +25 -2
- package/src/enrichApollo.ts +5 -2
- package/src/index.ts +8 -0
- package/src/integrity.ts +146 -0
- package/src/market.ts +129 -8
- package/src/marketReport.ts +30 -4
- package/src/marketTaxonomy.ts +288 -0
- package/src/planStore.ts +23 -4
- package/src/schedule.ts +98 -2
- package/src/types.ts +16 -0
package/src/enrichApollo.ts
CHANGED
|
@@ -78,9 +78,12 @@ export function createApolloClient(options: ApolloClientOptions): ApolloClient {
|
|
|
78
78
|
}
|
|
79
79
|
if (response.status === 404) return null;
|
|
80
80
|
if (!response.ok) {
|
|
81
|
-
|
|
81
|
+
// Status line only — never interpolate the response body. It can echo
|
|
82
|
+
// the submitted query (contact emails / company domains) or the API key,
|
|
83
|
+
// and these errors are persisted verbatim into scheduled-run records.
|
|
84
|
+
await response.text().catch(() => undefined);
|
|
82
85
|
const exhausted = response.status === 429 ? ` (rate limited; ${maxRetries} retries exhausted)` : "";
|
|
83
|
-
throw new Error(`Apollo API error ${response.status}${exhausted}
|
|
86
|
+
throw new Error(`Apollo API error ${response.status}${exhausted}. Check the API key and request.`);
|
|
84
87
|
}
|
|
85
88
|
const text = await response.text();
|
|
86
89
|
return text ? (JSON.parse(text) as Record<string, unknown>) : null;
|
package/src/index.ts
CHANGED
|
@@ -115,6 +115,14 @@ export {
|
|
|
115
115
|
type MergeSuggestion,
|
|
116
116
|
} from "./merge.ts";
|
|
117
117
|
export { createFilePlanStore, type PlanStore, type StoredPlan } from "./planStore.ts";
|
|
118
|
+
export {
|
|
119
|
+
computeApprovalDigests,
|
|
120
|
+
loadOrCreateSigningKey,
|
|
121
|
+
loadSigningKey,
|
|
122
|
+
signApproval,
|
|
123
|
+
verifyApprovalDigests,
|
|
124
|
+
type ApprovalVerification,
|
|
125
|
+
} from "./integrity.ts";
|
|
118
126
|
export { formatPatchPlanRun, patchPlanToMarkdown } from "./format.ts";
|
|
119
127
|
export { auditReportToHtml, auditReportToMarkdown, type ReportOptions } from "./report.ts";
|
|
120
128
|
export {
|
package/src/integrity.ts
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { createHmac, randomBytes } from "node:crypto";
|
|
2
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { credentialsDir, ensureSecureHomeDir, writeSecureFile } from "./credentials.ts";
|
|
5
|
+
import type { PatchOperation } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Approval integrity.
|
|
9
|
+
*
|
|
10
|
+
* The plan store records WHICH operation ids a human approved, but the apply
|
|
11
|
+
* path re-reads the operation BODIES fresh from the (user-editable) plan file.
|
|
12
|
+
* Nothing bound the approval to the content: an approved op's afterValue or
|
|
13
|
+
* objectId could be changed on disk between `plans approve` and `apply` — by a
|
|
14
|
+
* compromised dependency, a co-tenant, or a plan file synced/edited on another
|
|
15
|
+
* machine — and the changed value would be written under the prior approval.
|
|
16
|
+
*
|
|
17
|
+
* Fix: at approval time, HMAC-sign each approved operation's security-relevant
|
|
18
|
+
* content (including the approved value override) with a per-install secret key
|
|
19
|
+
* stored 0600 alongside the credentials. At apply time, recompute and verify.
|
|
20
|
+
* Any post-approval edit to the operations or the approved overrides changes the
|
|
21
|
+
* signature; a tamper must now also forge an HMAC it cannot compute without the
|
|
22
|
+
* key. The key never leaves the machine, so a plan approved here and applied
|
|
23
|
+
* elsewhere fails closed ("re-approve on this machine") rather than open.
|
|
24
|
+
*
|
|
25
|
+
* This raises the bar from "trust the plan JSON" to "trust the plan JSON only
|
|
26
|
+
* insofar as it still matches what was signed with the local key." It is not a
|
|
27
|
+
* defense against an attacker who already holds the signing key (same-dir, same
|
|
28
|
+
* permissions as the credential store) — that is the documented boundary.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const SIGNING_KEY_FILE = ".plan-signing-key";
|
|
32
|
+
|
|
33
|
+
function signingKeyPath(): string {
|
|
34
|
+
return join(credentialsDir(), SIGNING_KEY_FILE);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Read the signing key, or null if it has not been created yet. */
|
|
38
|
+
export function loadSigningKey(): Buffer | null {
|
|
39
|
+
const path = signingKeyPath();
|
|
40
|
+
if (!existsSync(path)) return null;
|
|
41
|
+
try {
|
|
42
|
+
return Buffer.from(readFileSync(path, "utf8").trim(), "hex");
|
|
43
|
+
} catch {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Read the signing key, creating a fresh 32-byte one (0600) on first use. */
|
|
49
|
+
export function loadOrCreateSigningKey(): Buffer {
|
|
50
|
+
const existing = loadSigningKey();
|
|
51
|
+
if (existing && existing.length >= 32) return existing;
|
|
52
|
+
ensureSecureHomeDir();
|
|
53
|
+
const key = randomBytes(32);
|
|
54
|
+
writeSecureFile(signingKeyPath(), `${key.toString("hex")}\n`);
|
|
55
|
+
return key;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Canonical, stable string of the operation content an approval binds to. Only
|
|
60
|
+
* the fields that determine WHAT gets written: changing any of them must
|
|
61
|
+
* invalidate the approval. `override` is the approved value override for this op
|
|
62
|
+
* (the value actually written when set), so tampering with stored overrides is
|
|
63
|
+
* caught too.
|
|
64
|
+
*/
|
|
65
|
+
function canonicalApprovalContent(operation: PatchOperation, override: unknown): string {
|
|
66
|
+
return JSON.stringify([
|
|
67
|
+
operation.id,
|
|
68
|
+
operation.operation,
|
|
69
|
+
operation.objectType,
|
|
70
|
+
operation.objectId,
|
|
71
|
+
operation.field ?? null,
|
|
72
|
+
operation.beforeValue ?? null,
|
|
73
|
+
operation.afterValue ?? null,
|
|
74
|
+
operation.groupId ?? null,
|
|
75
|
+
// Safety-relevant fields too: editing a precondition could relax a drift
|
|
76
|
+
// guard, and forging forceArchiveDuplicate could suppress the archive-of-
|
|
77
|
+
// duplicate refusal — the signed approval must pin apply BEHAVIOR, not just
|
|
78
|
+
// the written value. `reason` is human-reviewed AND written verbatim into
|
|
79
|
+
// create_task bodies (afterValue ?? reason fallback in the connectors), so a
|
|
80
|
+
// create_task with a null afterValue would otherwise let a disk edit to
|
|
81
|
+
// reason write unapproved text under a still-valid digest.
|
|
82
|
+
operation.preconditions ?? null,
|
|
83
|
+
operation.forceArchiveDuplicate ?? false,
|
|
84
|
+
operation.reason ?? null,
|
|
85
|
+
override === undefined ? null : ["__override__", override],
|
|
86
|
+
]);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** HMAC-SHA256 signature of one operation's approved content. */
|
|
90
|
+
export function signApproval(operation: PatchOperation, override: unknown, key: Buffer): string {
|
|
91
|
+
return createHmac("sha256", key).update(canonicalApprovalContent(operation, override)).digest("hex");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Compute the approval signature map for a set of approved operation ids,
|
|
96
|
+
* resolving each op from the plan and its (approved) value override.
|
|
97
|
+
*/
|
|
98
|
+
export function computeApprovalDigests(
|
|
99
|
+
operations: PatchOperation[],
|
|
100
|
+
approvedOperationIds: string[],
|
|
101
|
+
valueOverrides: Record<string, unknown>,
|
|
102
|
+
key: Buffer,
|
|
103
|
+
): Record<string, string> {
|
|
104
|
+
const byId = new Map(operations.map((operation) => [operation.id, operation]));
|
|
105
|
+
const digests: Record<string, string> = {};
|
|
106
|
+
for (const id of approvedOperationIds) {
|
|
107
|
+
const operation = byId.get(id);
|
|
108
|
+
if (!operation) continue;
|
|
109
|
+
digests[id] = signApproval(operation, valueOverrides[id], key);
|
|
110
|
+
}
|
|
111
|
+
return digests;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export type ApprovalVerification =
|
|
115
|
+
| { ok: true }
|
|
116
|
+
| { ok: false; reason: "no_key"; tampered: string[] }
|
|
117
|
+
| { ok: false; reason: "mismatch"; tampered: string[] };
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Verify that every approved operation still matches what was signed. Returns
|
|
121
|
+
* ok:true when there are no stored digests (a pre-integrity plan — nothing to
|
|
122
|
+
* verify), when all match, or fails with the list of operation ids whose
|
|
123
|
+
* content changed since approval.
|
|
124
|
+
*/
|
|
125
|
+
export function verifyApprovalDigests(
|
|
126
|
+
operations: PatchOperation[],
|
|
127
|
+
approvedOperationIds: string[],
|
|
128
|
+
valueOverrides: Record<string, unknown>,
|
|
129
|
+
storedDigests: Record<string, string> | undefined,
|
|
130
|
+
): ApprovalVerification {
|
|
131
|
+
if (!storedDigests || Object.keys(storedDigests).length === 0) return { ok: true };
|
|
132
|
+
const key = loadSigningKey();
|
|
133
|
+
if (!key) return { ok: false, reason: "no_key", tampered: approvedOperationIds };
|
|
134
|
+
const byId = new Map(operations.map((operation) => [operation.id, operation]));
|
|
135
|
+
const tampered: string[] = [];
|
|
136
|
+
for (const id of approvedOperationIds) {
|
|
137
|
+
const operation = byId.get(id);
|
|
138
|
+
const expected = storedDigests[id];
|
|
139
|
+
if (!operation || !expected) {
|
|
140
|
+
tampered.push(id);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (signApproval(operation, valueOverrides[id], key) !== expected) tampered.push(id);
|
|
144
|
+
}
|
|
145
|
+
return tampered.length === 0 ? { ok: true } : { ok: false, reason: "mismatch", tampered };
|
|
146
|
+
}
|
package/src/market.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
|
+
import { lookup } from "node:dns/promises";
|
|
2
3
|
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { isIP } from "node:net";
|
|
3
5
|
import { join } from "node:path";
|
|
4
6
|
import { credentialsDir } from "./credentials.ts";
|
|
5
7
|
import type { GtmEvidence } from "./types.ts";
|
|
@@ -309,15 +311,129 @@ export function extractReadableText(html: string): string {
|
|
|
309
311
|
|
|
310
312
|
export type FetchPage = (url: string) => Promise<{ status: number; body: string }>;
|
|
311
313
|
|
|
314
|
+
/**
|
|
315
|
+
* SSRF guard. market.config.json URLs are operator-authored, but configs are
|
|
316
|
+
* shared/templated in consulting/team use and `market capture|refresh` is on
|
|
317
|
+
* the cron allowlist — an unguarded fetch is an unattended internal-network
|
|
318
|
+
* and cloud-metadata probe. We therefore (1) allow only http/https, (2) refuse
|
|
319
|
+
* any host that is or resolves to a private/loopback/link-local/metadata
|
|
320
|
+
* address, and (3) follow redirects manually, re-validating each hop.
|
|
321
|
+
*
|
|
322
|
+
* Residual gap (documented, not defended here): TOCTOU DNS rebinding between
|
|
323
|
+
* our lookup and fetch's own resolution. Out of scope for fetching public
|
|
324
|
+
* competitor pages; a hardened deployment should fetch through an egress proxy.
|
|
325
|
+
*/
|
|
326
|
+
const MAX_REDIRECTS = 5;
|
|
327
|
+
const FETCH_TIMEOUT_MS = 15_000;
|
|
328
|
+
const MAX_BODY_BYTES = 5_000_000;
|
|
329
|
+
|
|
330
|
+
function ipv4IsPrivate(ip: string): boolean {
|
|
331
|
+
const parts = ip.split(".").map((n) => Number(n));
|
|
332
|
+
if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return true;
|
|
333
|
+
const [a, b] = parts;
|
|
334
|
+
if (a === 0 || a === 127) return true; // this-host, loopback
|
|
335
|
+
if (a === 10) return true; // private
|
|
336
|
+
if (a === 172 && b >= 16 && b <= 31) return true; // private
|
|
337
|
+
if (a === 192 && b === 168) return true; // private
|
|
338
|
+
if (a === 169 && b === 254) return true; // link-local incl. 169.254.169.254 metadata
|
|
339
|
+
if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT
|
|
340
|
+
if (a >= 224) return true; // multicast / reserved
|
|
341
|
+
return false;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function ipIsPrivate(ip: string): boolean {
|
|
345
|
+
const family = isIP(ip);
|
|
346
|
+
if (family === 4) return ipv4IsPrivate(ip);
|
|
347
|
+
if (family === 6) {
|
|
348
|
+
const lower = ip.toLowerCase();
|
|
349
|
+
if (lower === "::1" || lower === "::") return true; // loopback / unspecified
|
|
350
|
+
// IPv4-mapped (::ffff:…) — Node normalizes ::ffff:127.0.0.1 to ::ffff:7f00:1,
|
|
351
|
+
// so accept both the dotted and the hex-pair forms, unwrap, check the v4.
|
|
352
|
+
const mapped = lower.match(/^::ffff:(.+)$/);
|
|
353
|
+
if (mapped) {
|
|
354
|
+
const rest = mapped[1];
|
|
355
|
+
if (rest.includes(".")) return ipv4IsPrivate(rest);
|
|
356
|
+
const groups = rest.split(":");
|
|
357
|
+
if (groups.length === 2) {
|
|
358
|
+
const hi = parseInt(groups[0], 16);
|
|
359
|
+
const lo = parseInt(groups[1], 16);
|
|
360
|
+
if (Number.isNaN(hi) || Number.isNaN(lo)) return true;
|
|
361
|
+
return ipv4IsPrivate(`${(hi >> 8) & 0xff}.${hi & 0xff}.${(lo >> 8) & 0xff}.${lo & 0xff}`);
|
|
362
|
+
}
|
|
363
|
+
return true; // unrecognized mapped form → refuse
|
|
364
|
+
}
|
|
365
|
+
if (lower.startsWith("fe8") || lower.startsWith("fe9") || lower.startsWith("fea") || lower.startsWith("feb")) return true; // link-local fe80::/10
|
|
366
|
+
if (lower.startsWith("fc") || lower.startsWith("fd")) return true; // unique-local fc00::/7
|
|
367
|
+
return false;
|
|
368
|
+
}
|
|
369
|
+
return true; // not a recognizable IP literal → refuse
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
export async function assertPublicUrl(rawUrl: string): Promise<URL> {
|
|
373
|
+
let url: URL;
|
|
374
|
+
try {
|
|
375
|
+
url = new URL(rawUrl);
|
|
376
|
+
} catch {
|
|
377
|
+
throw new Error(`market capture: "${rawUrl}" is not a valid URL.`);
|
|
378
|
+
}
|
|
379
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
380
|
+
throw new Error(`market capture refuses ${url.protocol} URLs (only http/https): ${rawUrl}`);
|
|
381
|
+
}
|
|
382
|
+
const host = url.hostname.replace(/^\[|\]$/g, ""); // strip IPv6 brackets
|
|
383
|
+
if (isIP(host)) {
|
|
384
|
+
if (ipIsPrivate(host)) throw new Error(`market capture refuses private/loopback address ${host} (SSRF guard).`);
|
|
385
|
+
return url;
|
|
386
|
+
}
|
|
387
|
+
// Hostname: resolve and refuse if ANY address is private.
|
|
388
|
+
const addrs = await lookup(host, { all: true });
|
|
389
|
+
for (const { address } of addrs) {
|
|
390
|
+
if (ipIsPrivate(address)) {
|
|
391
|
+
throw new Error(`market capture refuses ${host} — it resolves to private/internal address ${address} (SSRF guard).`);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
return url;
|
|
395
|
+
}
|
|
396
|
+
|
|
312
397
|
const defaultFetchPage: FetchPage = async (url) => {
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
398
|
+
let current = url;
|
|
399
|
+
for (let hop = 0; hop <= MAX_REDIRECTS; hop++) {
|
|
400
|
+
await assertPublicUrl(current);
|
|
401
|
+
const controller = new AbortController();
|
|
402
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
|
403
|
+
let response: Response;
|
|
404
|
+
try {
|
|
405
|
+
response = await fetch(current, {
|
|
406
|
+
headers: {
|
|
407
|
+
"User-Agent": "fullstackgtm-market/0 (+https://github.com/fullstackgtm/core)",
|
|
408
|
+
"Accept-Language": "en-US",
|
|
409
|
+
},
|
|
410
|
+
redirect: "manual",
|
|
411
|
+
signal: controller.signal,
|
|
412
|
+
});
|
|
413
|
+
} finally {
|
|
414
|
+
clearTimeout(timer);
|
|
415
|
+
}
|
|
416
|
+
if (response.status >= 300 && response.status < 400 && response.headers.get("location")) {
|
|
417
|
+
current = new URL(response.headers.get("location") as string, current).toString();
|
|
418
|
+
continue; // re-validate the redirect target on the next iteration
|
|
419
|
+
}
|
|
420
|
+
const reader = response.body?.getReader();
|
|
421
|
+
if (!reader) return { status: response.status, body: await response.text() };
|
|
422
|
+
const chunks: Uint8Array[] = [];
|
|
423
|
+
let total = 0;
|
|
424
|
+
for (;;) {
|
|
425
|
+
const { done, value } = await reader.read();
|
|
426
|
+
if (done) break;
|
|
427
|
+
total += value.length;
|
|
428
|
+
if (total > MAX_BODY_BYTES) {
|
|
429
|
+
await reader.cancel();
|
|
430
|
+
break;
|
|
431
|
+
}
|
|
432
|
+
chunks.push(value);
|
|
433
|
+
}
|
|
434
|
+
return { status: response.status, body: Buffer.concat(chunks).toString("utf8") };
|
|
435
|
+
}
|
|
436
|
+
throw new Error(`market capture: too many redirects (>${MAX_REDIRECTS}) for ${url}`);
|
|
321
437
|
};
|
|
322
438
|
|
|
323
439
|
export type CaptureOptions = {
|
|
@@ -478,6 +594,11 @@ export function validateObservationSet(config: MarketConfig, set: ObservationSet
|
|
|
478
594
|
if (!INTENSITY_RANK[obs.intensity] && obs.intensity !== "unobservable") {
|
|
479
595
|
problems.push(`${cell}: invalid intensity "${obs.intensity}"`);
|
|
480
596
|
}
|
|
597
|
+
// confidence is rendered into the HTML report; only the enum is allowed, so
|
|
598
|
+
// an `observe --from` file can't smuggle markup through a free-text value.
|
|
599
|
+
if (obs.confidence !== "high" && obs.confidence !== "medium" && obs.confidence !== "low") {
|
|
600
|
+
problems.push(`${cell}: invalid confidence "${String(obs.confidence)}" (expected high, medium, or low)`);
|
|
601
|
+
}
|
|
481
602
|
if ((obs.intensity === "loud" || obs.intensity === "quiet") && obs.evidence.length === 0) {
|
|
482
603
|
problems.push(`${cell}: ${obs.intensity} reading with no quoted evidence`);
|
|
483
604
|
}
|
package/src/marketReport.ts
CHANGED
|
@@ -40,6 +40,23 @@ function escapeHtml(value: string): string {
|
|
|
40
40
|
.replace(/"/g, """);
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* Serialize JSON for embedding inside an inline <script> block. JSON.stringify
|
|
45
|
+
* does not escape `<`, `>`, `&`, or the U+2028/U+2029 line separators, so a
|
|
46
|
+
* vendor name containing `</script>` (these are untrusted, competitor-authored
|
|
47
|
+
* strings) would close the tag and inject markup. Replacing them with their
|
|
48
|
+
* \uXXXX escapes keeps the parsed value identical while making the breakout
|
|
49
|
+
* sequence unrepresentable in the HTML source.
|
|
50
|
+
*/
|
|
51
|
+
export function safeJsonForScript(value: unknown): string {
|
|
52
|
+
return JSON.stringify(value)
|
|
53
|
+
.replace(/</g, "\\u003c")
|
|
54
|
+
.replace(/>/g, "\\u003e")
|
|
55
|
+
.replace(/&/g, "\\u0026")
|
|
56
|
+
.replace(/\u2028/g, "\\u2028")
|
|
57
|
+
.replace(/\u2029/g, "\\u2029");
|
|
58
|
+
}
|
|
59
|
+
|
|
43
60
|
type MapModel = {
|
|
44
61
|
config: MarketConfig;
|
|
45
62
|
set: ObservationSet;
|
|
@@ -374,7 +391,7 @@ function axisSectionsHtml(
|
|
|
374
391
|
<table class="legend"><thead><tr><th></th><th>vendor</th><th class="num">${legendMeasureHead}</th></tr></thead><tbody>${legendRows}</tbody></table>
|
|
375
392
|
</div>
|
|
376
393
|
<div class="map-tip" id="map-tip" hidden></div>
|
|
377
|
-
<script type="application/json" id="map-data">${
|
|
394
|
+
<script type="application/json" id="map-data">${safeJsonForScript(tipData)}</script>
|
|
378
395
|
<script>
|
|
379
396
|
(function () {
|
|
380
397
|
var data = JSON.parse(document.getElementById("map-data").textContent);
|
|
@@ -385,7 +402,16 @@ function axisSectionsHtml(
|
|
|
385
402
|
function show(v, evt) {
|
|
386
403
|
var d = data[v];
|
|
387
404
|
if (!d) return;
|
|
388
|
-
|
|
405
|
+
// textContent only — vendor names / axis labels are untrusted (competitor-controlled).
|
|
406
|
+
tip.textContent = "";
|
|
407
|
+
var head = document.createElement("b");
|
|
408
|
+
head.textContent = d.n + " · " + d.name;
|
|
409
|
+
tip.appendChild(head);
|
|
410
|
+
d.lines.forEach(function (l) {
|
|
411
|
+
var div = document.createElement("div");
|
|
412
|
+
div.textContent = l;
|
|
413
|
+
tip.appendChild(div);
|
|
414
|
+
});
|
|
389
415
|
tip.hidden = false;
|
|
390
416
|
var box = fig.getBoundingClientRect();
|
|
391
417
|
tip.style.left = Math.min(evt.clientX - box.left + 14, box.width - tip.offsetWidth - 8) + "px";
|
|
@@ -481,7 +507,7 @@ export function marketMapToHtml(config: MarketConfig, set: ObservationSet): stri
|
|
|
481
507
|
const anchorLoud = anchor
|
|
482
508
|
? claimIds.filter((claimId) => model.cell(anchor, claimId)?.intensity === "loud").length
|
|
483
509
|
: 0;
|
|
484
|
-
const anchorNote = anchor ? ` · ${vendorNamesById.get(anchor) ?? anchor} loud on ${anchorLoud}` : "";
|
|
510
|
+
const anchorNote = anchor ? ` · ${e(vendorNamesById.get(anchor) ?? anchor)} loud on ${anchorLoud}` : "";
|
|
485
511
|
return `<details class="claim-group"><summary><b>${e(group.title)}</b> — ${claimIds.length} claim${claimIds.length === 1 ? "" : "s"} <span class="sum-soft">(${e(group.blurb)}${anchorNote})</span></summary>
|
|
486
512
|
<table><thead><tr><th></th>${vendorHeads}<th></th></tr></thead><tbody>${claimIds.map(matrixRow).join("")}</tbody></table>
|
|
487
513
|
</details>`;
|
|
@@ -543,7 +569,7 @@ export function marketMapToHtml(config: MarketConfig, set: ObservationSet): stri
|
|
|
543
569
|
if (!obs || obs.evidence.length === 0) return [];
|
|
544
570
|
return obs.evidence.map(
|
|
545
571
|
(evidence) =>
|
|
546
|
-
`<div class="ev"><span class="ev-head">${e(claimId)} · ${obs.intensity.toUpperCase()} (${obs.confidence})</span>` +
|
|
572
|
+
`<div class="ev"><span class="ev-head">${e(claimId)} · ${e(obs.intensity.toUpperCase())} (${e(String(obs.confidence ?? ""))})</span>` +
|
|
547
573
|
`<blockquote>“${e(evidence.text)}”</blockquote>` +
|
|
548
574
|
`<span class="ev-src">${e(String(evidence.metadata?.url ?? ""))} · capture ${e(String(evidence.metadata?.captureHash ?? "").slice(0, 12))}</span></div>`,
|
|
549
575
|
);
|